From 99dfd23e45a966120d7708e714eb35b322dd90e5 Mon Sep 17 00:00:00 2001 From: theuntraceable Date: Wed, 21 Dec 2022 12:00:06 +0600 Subject: [PATCH 001/335] Get back to start --- .readthedocs.yml | 23 - EpikCord/__init__.py | 55 -- EpikCord/__main__.py | 13 - EpikCord/abstract.py | 678 ------------------- EpikCord/application/__init__.py | 3 - EpikCord/application/application.py | 68 -- EpikCord/application/application_command.py | 101 --- EpikCord/application/team.py | 29 - EpikCord/auto_moderation.py | 150 ----- EpikCord/bin/COPYING | 28 - EpikCord/bin/libopus-0.x64.dll | Bin 441856 -> 0 bytes EpikCord/bin/libopus-0.x86.dll | Bin 366080 -> 0 bytes EpikCord/channels.py | 245 ------- EpikCord/client/__init__.py | 8 - EpikCord/client/client.py | 82 --- EpikCord/client/client_application.py | 292 --------- EpikCord/client/client_user.py | 43 -- EpikCord/client/command_handler.py | 237 ------- EpikCord/client/http_client.py | 312 --------- EpikCord/client/sections.py | 28 - EpikCord/client/user_client.py | 110 ---- EpikCord/client/webhook_client.py | 11 - EpikCord/client/websocket_client.py | 461 ------------- EpikCord/close_event_codes.py | 55 -- EpikCord/close_handler.py | 80 --- EpikCord/colour.py | 142 ---- EpikCord/commands.py | 137 ---- EpikCord/components.py | 515 --------------- EpikCord/exceptions.py | 162 ----- EpikCord/ext/__init__.py | 1 - EpikCord/ext/tasks.py | 41 -- EpikCord/flags.py | 131 ---- EpikCord/guild.py | 684 -------------------- EpikCord/interactions.py | 252 -------- EpikCord/localizations.py | 15 - EpikCord/managers/__init__.py | 24 - EpikCord/managers/cache_manager.py | 80 --- EpikCord/managers/channel_manager.py | 23 - EpikCord/managers/guilds_manager.py | 30 - EpikCord/managers/member_manager.py | 15 - EpikCord/managers/roles_manager.py | 0 EpikCord/mentioned.py | 29 - EpikCord/message.py | 577 ----------------- EpikCord/opcodes.py | 41 -- EpikCord/options.py | 247 ------- EpikCord/partials.py | 66 -- EpikCord/presence.py | 144 ----- EpikCord/py.typed | 0 EpikCord/rtp_handler.py | 79 --- EpikCord/sharding.py | 133 ---- EpikCord/status_code.py | 272 -------- EpikCord/sticker.py | 92 --- EpikCord/thread.py | 123 ---- EpikCord/type_enums.py | 165 ----- EpikCord/user.py | 38 -- EpikCord/utils/__init__.py | 3 - EpikCord/utils/command_utils.py | 18 - EpikCord/utils/paginator.py | 52 -- EpikCord/utils/utils.py | 285 -------- EpikCord/voice.py | 47 -- EpikCord/webhooks.py | 59 -- EpikCord/ws_events.py | 61 -- docs/EpikCord.application.rst | 37 -- docs/EpikCord.client.rst | 85 --- docs/EpikCord.ext.rst | 21 - docs/EpikCord.managers.rst | 53 -- docs/EpikCord.rst | 257 -------- docs/EpikCord.utils.rst | 37 -- docs/Makefile | 20 - docs/conf.py | 54 -- docs/index.rst | 22 - docs/make.bat | 35 - docs/modules.rst | 7 - examples/basic_bot.py | 13 - examples/event.py | 31 - examples/message.py | 24 - examples/message_commands.py | 18 - examples/slash_commands.py | 53 -- examples/user_commands.py | 18 - 79 files changed, 8680 deletions(-) delete mode 100644 .readthedocs.yml delete mode 100644 EpikCord/__init__.py delete mode 100644 EpikCord/__main__.py delete mode 100644 EpikCord/abstract.py delete mode 100644 EpikCord/application/__init__.py delete mode 100644 EpikCord/application/application.py delete mode 100644 EpikCord/application/application_command.py delete mode 100644 EpikCord/application/team.py delete mode 100644 EpikCord/auto_moderation.py delete mode 100644 EpikCord/bin/COPYING delete mode 100644 EpikCord/bin/libopus-0.x64.dll delete mode 100644 EpikCord/bin/libopus-0.x86.dll delete mode 100644 EpikCord/channels.py delete mode 100644 EpikCord/client/__init__.py delete mode 100644 EpikCord/client/client.py delete mode 100644 EpikCord/client/client_application.py delete mode 100644 EpikCord/client/client_user.py delete mode 100644 EpikCord/client/command_handler.py delete mode 100644 EpikCord/client/http_client.py delete mode 100644 EpikCord/client/sections.py delete mode 100644 EpikCord/client/user_client.py delete mode 100644 EpikCord/client/webhook_client.py delete mode 100644 EpikCord/client/websocket_client.py delete mode 100644 EpikCord/close_event_codes.py delete mode 100644 EpikCord/close_handler.py delete mode 100644 EpikCord/colour.py delete mode 100644 EpikCord/commands.py delete mode 100644 EpikCord/components.py delete mode 100644 EpikCord/exceptions.py delete mode 100644 EpikCord/ext/__init__.py delete mode 100644 EpikCord/ext/tasks.py delete mode 100644 EpikCord/flags.py delete mode 100644 EpikCord/guild.py delete mode 100644 EpikCord/interactions.py delete mode 100644 EpikCord/localizations.py delete mode 100644 EpikCord/managers/__init__.py delete mode 100644 EpikCord/managers/cache_manager.py delete mode 100644 EpikCord/managers/channel_manager.py delete mode 100644 EpikCord/managers/guilds_manager.py delete mode 100644 EpikCord/managers/member_manager.py delete mode 100644 EpikCord/managers/roles_manager.py delete mode 100644 EpikCord/mentioned.py delete mode 100644 EpikCord/message.py delete mode 100644 EpikCord/opcodes.py delete mode 100644 EpikCord/options.py delete mode 100644 EpikCord/partials.py delete mode 100644 EpikCord/presence.py delete mode 100644 EpikCord/py.typed delete mode 100644 EpikCord/rtp_handler.py delete mode 100644 EpikCord/sharding.py delete mode 100644 EpikCord/status_code.py delete mode 100644 EpikCord/sticker.py delete mode 100644 EpikCord/thread.py delete mode 100644 EpikCord/type_enums.py delete mode 100644 EpikCord/user.py delete mode 100644 EpikCord/utils/__init__.py delete mode 100644 EpikCord/utils/command_utils.py delete mode 100644 EpikCord/utils/paginator.py delete mode 100644 EpikCord/utils/utils.py delete mode 100644 EpikCord/voice.py delete mode 100644 EpikCord/webhooks.py delete mode 100644 EpikCord/ws_events.py delete mode 100644 docs/EpikCord.application.rst delete mode 100644 docs/EpikCord.client.rst delete mode 100644 docs/EpikCord.ext.rst delete mode 100644 docs/EpikCord.managers.rst delete mode 100644 docs/EpikCord.rst delete mode 100644 docs/EpikCord.utils.rst delete mode 100644 docs/Makefile delete mode 100644 docs/conf.py delete mode 100644 docs/index.rst delete mode 100644 docs/make.bat delete mode 100644 docs/modules.rst delete mode 100644 examples/basic_bot.py delete mode 100644 examples/event.py delete mode 100644 examples/message.py delete mode 100644 examples/message_commands.py delete mode 100644 examples/slash_commands.py delete mode 100644 examples/user_commands.py diff --git a/.readthedocs.yml b/.readthedocs.yml deleted file mode 100644 index f147b11a..00000000 --- a/.readthedocs.yml +++ /dev/null @@ -1,23 +0,0 @@ -version: 2 - -# Set the version of Python and other tools you might need -build: - os: ubuntu-20.04 - tools: - python: "3.9" -# commands: -# - pip install sphinx sphinx_rtd_theme -# - python3 -m sphinx -b html docs/source /html - - -# Build documentation in the docs/ directory with Sphinx -sphinx: - configuration: docs/source/conf.py - - -python: - install: - - requirements: requirements.txt - -submodules: - include: all diff --git a/EpikCord/__init__.py b/EpikCord/__init__.py deleted file mode 100644 index 69fdadc3..00000000 --- a/EpikCord/__init__.py +++ /dev/null @@ -1,55 +0,0 @@ -""" -:license: -Some parts of the code is sourced from discord.py -The MIT License (MIT) -Copyright © 2015-2021 Rapptz -Copyright © 2021-present EpikHost -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, RESS 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 .abstract import * -from .application import * -from .auto_moderation import * -from .channels import * -from .client import * -from .close_event_codes import * -from .colour import * -from .commands import * -from .components import * -from .exceptions import * -from .flags import * -from .guild import * -from .interactions import * -from .localizations import * -from .managers import * -from .mentioned import * -from .message import * -from .opcodes import * -from .options import * -from .partials import * -from .presence import * -from .rtp_handler import * -from .sharding import * -from .status_code import * -from .sticker import * -from .thread import * -from .type_enums import * -from .user import * -from .utils import * -from .voice import * -from .webhooks import * - -__version__ = "0.5.4" -__all__ = ("__version__",) diff --git a/EpikCord/__main__.py b/EpikCord/__main__.py deleted file mode 100644 index b1ad0527..00000000 --- a/EpikCord/__main__.py +++ /dev/null @@ -1,13 +0,0 @@ -from EpikCord import __version__ - - -def info(): - print( - f"Version {__version__} of EpikCord.py, written by the organization EpikCord. " - "This is an unstable build and may contain bugs. " - "Please report any bugs to https://github.com/EpikCord/EpikCord.py/issues." - ) - - -if __name__ == "__main__": - info() diff --git a/EpikCord/abstract.py b/EpikCord/abstract.py deleted file mode 100644 index d35a217c..00000000 --- a/EpikCord/abstract.py +++ /dev/null @@ -1,678 +0,0 @@ -from __future__ import annotations - -import asyncio -import socket -import struct -from abc import abstractmethod -from importlib.util import find_spec -from logging import getLogger -from typing import TYPE_CHECKING, Dict, List, Optional, Union - -from aiohttp import ClientWebSocketResponse - -from .close_event_codes import GatewayCECode -from .exceptions import ( - ClosedWebSocketConnection, - CustomIdIsTooBig, - InvalidArgumentType, - InvalidData, -) -from .opcodes import GatewayOpcode, VoiceOpcode -from .type_enums import AllowedMentionTypes - -logger = getLogger("EpikCord.channels") - -_NACL = find_spec("nacl") - -if TYPE_CHECKING: - from .client import Client - -if _NACL: - import nacl # type: ignore - -else: - logger.warning( - "The PyNacl library was not found, so voice is not supported." - " Please install it by doing ``pip install PyNaCl``" - " If you want voice support" - ) - - -if TYPE_CHECKING: - import discord_typings - - from EpikCord import ( - AllowedMention, - Attachment, - Check, - Embed, - GuildStageChannel, - Message, - MessagePayload, - Modal, - Overwrite, - VoiceChannel, - ) - - from .components import * - - -class TypingContextManager: - def __init__(self, client: Client, channel_id): - self.typing: Optional[asyncio.Task] = None - self.client = client - self.channel_id: str = channel_id - - async def start_typing(self): - - await self.client.http.post(f"/channels/{self.channel_id}/typing") - asyncio.get_event_loop().call_later(10, self.start_typing) - - async def __aenter__(self): - self.typing = asyncio.create_task(self.start_typing()) - - async def __aexit__(self): - self.typing.cancel() - - -class Messageable: - def __init__(self, client: Client, channel_id: int): - self.id: int = int(channel_id) - self.client = client - - async def fetch_pinned_messages(self) -> List[Message]: - from EpikCord import Message - - response = await self.client.http.get( - f"/channels/{self.id}/pins", channel_id=self.id - ) - data = await response.json() - return [Message(self.client, message) for message in data] - - async def fetch_messages( - self, - *, - around: Optional[int] = None, - before: Optional[int] = None, - after: Optional[int] = None, - limit: Optional[int] = None, - ) -> List[Message]: - from EpikCord import Message - - response = await self.client.http.get( - f"channels/{self.id}/messages", - params={"around": around, "before": before, "after": after, "limit": limit}, - ) - data = await response.json() - return [Message(self.client, message) for message in data] - - async def fetch_message(self, *, message_id: str) -> Message: - from EpikCord import Message - - response = await self.client.http.get( - f"channels/{self.id}/messages/{message_id}" - ) - data = await response.json() - return Message(self.client, data) - - async def send( - self, - content: Optional[str] = None, - *, - embeds: List[Embed] = [], - components: List[ActionRow] = [], - tts: bool = False, - allowed_mention: Optional[AllowedMention] = None, - sticker_ids: Optional[List[str]] = None, - attachments: List[Attachment] = [], - suppress_embeds: bool = False, - ) -> Message: - from EpikCord import Message - - payload: MessagePayload = self.client.utils.filter_values( - { - "content": content, - "embeds": [embed.to_dict() for embed in embeds], - "components": [component.to_dict() for component in components], - "tts": tts, - "allowed_mentions": allowed_mention.to_dict() - if allowed_mention - else None, - "sticker_ids": sticker_ids, - "attachments": [attachment.to_dict() for attachment in attachments], - } - ) - - if suppress_embeds: - payload["flags"] = 1 << 2 - - response = await self.client.http.post( - f"channels/{self.id}/messages", json=payload - ) - data = await response.json() - return Message(self.client, data) - - async def typing(self) -> TypingContextManager: - return TypingContextManager(self.client, self.id) - - -class BaseCommand: - def __init__(self, checks: Optional[List[Check]] = None): - self.checks: List[Check] = checks or [] - - def is_slash_command(self): - return self.type == 1 - - def is_user_command(self): - return self.type == 2 - - def is_message_command(self): - return self.type == 3 - - @property - @abstractmethod - def type(self): - ... - - -class BaseChannel: - def __init__(self, client: Client, data: discord_typings.ChannelData): - self.id: int = int(data["id"]) - self.client = client - self.type: int = data["type"] - self.data: discord_typings.ChannelData = data - self.last_message_id: Optional[int] = ( - int(data["last_message_id"]) if data["last_message_id"] else None - ) - - -class Connectable: - def __init__( - self, - client: Client, - *, - channel: Union[VoiceChannel, GuildStageChannel], - ): - self.client = client - - self.channel_id = channel.id - self._closed = True - - self.token: Optional[str] = None - self.session_id: Optional[str] = None - self.endpoint: Optional[str] = None - self.socket: socket.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - self.socket.setblocking(False) - self.ws: Optional[ClientWebSocketResponse] = None - - self.heartbeat_interval: Optional[int] = None - self.server_ip: Optional[str] = None - self.server_port: Optional[int] = None - self.ssrc: Optional[int] = None - self.mode: Optional[List[str]] = None - self.secret_key: Optional[str] = None - - self.ip: Optional[str] = None - self.port: Optional[int] = None - - async def connect( - self, muted: Optional[bool] = False, deafened: Optional[bool] = False - ): - if not self.guild: - channel = await self.client.channels.fetch(self.channel_id) - if not channel: - raise InvalidData(f"Channel with Id {self.channel_id} does not exist.") - self.guild = channel.id - self.guild_id = channel.id - - await self.client.send_json( - { - "op": GatewayOpcode.VOICE_STATE_UPDATE, - "d": { - "guild_id": self.guild_id, - "channel_id": self.channel_id, - "self_mute": muted, - "self_deaf": deafened, - }, - } - ) - voice_state_update_coro = asyncio.create_task( - self.client.wait_for("voice_state_update") - ) - if not self.client.intents.voice_states: - raise ValueError( - "You must have the `voice_states` intent enabled to use " - "this otherwise we never get the session_id." - ) - - voice_server_update_coro = asyncio.create_task( - self.client.wait_for( - "voice_server_update", check=lambda data: data.get("endpoint") - ) - ) - events, _ = await asyncio.wait( - [voice_state_update_coro, voice_server_update_coro] - ) - from EpikCord import VoiceState - - for event in events: - if isinstance(event.result(), VoiceState): # If it's the VoiceState - self.session_id = event.result().session_id - elif isinstance(event.result(), dict): # If it's a VoiceServerUpdate - self.token = event.result()["token"] - self.endpoint = event.result()["endpoint"] - - await self._connect_ws() - - async def _connect_ws(self): - wss = "" if self.endpoint.startswith("wss://") else "wss://" - self.ws = await self.client.http.ws_connect(f"{wss}{self.endpoint}?v=4") - return await self.handle_events() - - async def handle_events(self): - async for event in self.ws: - event = event.json() - if event["op"] == VoiceOpcode.HELLO: - await self.handle_hello(event["d"]) - - elif event["op"] == VoiceOpcode.READY: - await self.handle_ready(event["d"]) - - await self.handle_close() - - async def handle_close(self): - self._closed = True - if self.ws.close_close == GatewayCECode.UnknownOpcode: - raise ClosedWebSocketConnection( - "EpikCord has sent an invalid OpCode to the Voice WebSocket." - " Report this at https://github.com/EpikCord/EpikCord.py/issues" - ) - elif self.ws.close_code == GatewayCECode.DecodeError: - raise ClosedWebSocketConnection( - "EpikCord has sent an invalid identify to the Voice WebSocket." - " Report this at https://github.com/EpikCord/EpikCord.py/issues" - ) - elif self.ws.close_code == GatewayCECode.NotAuthenticated: - raise ClosedWebSocketConnection( - "EpikCord has sent a payload before identifying to the Voice Websocket." - " Report this at https://github.com/EpikCord/EpikCord.py/issues" - ) - elif self.ws.close_code == GatewayCECode.AuthenticationFailed: - raise ClosedWebSocketConnection( - "EpikCord sent an invalid token to the Voice Websocket." - " Report this at https://github.com/EpikCord/EpikCord.py/issues" - ) - elif self.ws.close_code == GatewayCECode.AlreadyAuthenticated: - raise ClosedWebSocketConnection( - "EpikCord sent more than one identify payload." - " Report this at https://github.com/EpikCord/EpikCord.py/issues" - ) - elif self.ws.close_code == GatewayCECode.SessionTimedOut: - - raise ClosedWebSocketConnection("The session is no longer valid.") - - async def handle_hello(self, data: dict): - self.heartbeat_interval = data["heartbeat_interval"] - await self.identify() - - async def wrapper(): - while True: - await self.heartbeat() - await asyncio.sleep(self.heartbeat_interval / 1000) - - loop = asyncio.get_event_loop() - loop.create_task(wrapper()) - - async def handle_ready(self, event: dict): - self.ssrc = event["ssrc"] - self.mode = event["modes"][0] # Always has one mode, and I can use any. - self.server_ip = event["ip"] - self.server_port = event["port"] - - async def handle_session_description(self, event: dict): - self.secret_key = event["d"]["secret_key"] - - async def identify(self): - return await self.send_json( - { - "op": VoiceOpcode.IDENTIFY, - "d": { - "server_id": self.guild_id, - "user_id": self.client.user.id, - "session_id": self.session_id, - "token": self.token, - }, - } - ) - - async def select_protocol(self): - await self.send_json( - { - "op": VoiceOpcode.SELECT_PROTOCOL, - "d": { - "protocol": "udp", # I don't understand UDP tbh - "data": {"address": self.ip, "port": self.port, "mode": self.mode}, - }, - } - ) - - async def send_json(self, json, *args, **kwargs): - await self.ws.send_json(json, *args, **kwargs) - logger.info(f"Sent {json} to Voice Websocket {self.endpoint}") - - async def heartbeat(self): - heartbeat_nonce = nacl.utils.random(nacl.secret.SecretBox.NONCE_SIZE) - return await self.send_json({"op": VoiceOpcode.HEARTBEAT, "d": heartbeat_nonce}) - - async def discover_ip(self): - udp_packet: bytearray = bytearray(70) - struct.pack_into(">H", udp_packet, 0, 1) # Request. At the 0th Index, write 0x1 - struct.pack_into(">H", udp_packet, 2, 70) # Length of the packet. - struct.pack_into(">I", udp_packet, 4, self.ssrc) - self.socket.sendto(udp_packet, (self.server_ip, self.server_port)) - ip_data = await asyncio.get_event_loop().sock_recv(self.socket, 70) - # type + length = 4 - # We need to start at index 4 to get the address and ignore the type and length - ip_end = ip_data.index(0, 4) - self.ip = ip_data[4:ip_end].decode("ascii") - self.port = struct.unpack_from(">H", ip_data, len(ip_data) - 2)[0] - - -class BaseComponent: - def __init__(self, *, custom_id: str): - self.custom_id: str = custom_id - - def set_custom_id(self, custom_id: str): - if not isinstance(custom_id, str): - raise InvalidArgumentType("Custom Id must be a string.") - - elif len(custom_id) > 100: - raise CustomIdIsTooBig("Custom Id must be 100 characters or less.") - - self.custom_id = custom_id - - -class BaseInteraction: - def __init__(self, client: Client, data): - from EpikCord import GuildMember, User - - self.id: int = int(data["id"]) - self.data: discord_typings.InteractionData = data - self.client = client - self.type: int = data["type"] - self.application_id: int = int(data["application_id"]) - self.interaction_data: Optional[discord_typings.InteractionDataData] = data.get( - "data" - ) - self.guild_id: Optional[str] = data.get("guild_id") - self.channel_id: Optional[str] = data.get("channel_id") - self.author: Optional[Union[User, GuildMember]] = ( - GuildMember(client, data.get("member")) - if data.get("member") - else User(client, data.get("user")) - if data.get("user") - else None - ) - self.token: str = data.get("token") - self.version: int = data.get("version") - self.locale: Optional[str] = data.get("locale") - self.guild_locale: Optional[str] = data.get("guild_locale") - self.original_response: Optional[Message] = None - self.ephemeral: Optional[bool] - - async def reply( - self, - *, - tts: bool = False, - content: Optional[str] = None, - embeds: Optional[List[Embed]] = None, - allowed_mentions=None, - components: Optional[List[ActionRow]] = None, - attachments: Optional[List[Attachment]] = None, - suppress_embeds: Optional[bool] = False, - ephemeral: Optional[bool] = False, - ) -> None: - - message_data: MessagePayload = {"tts": tts, "flags": 0} - - if suppress_embeds: - message_data["flags"] |= 1 << 2 - if ephemeral: - message_data["flags"] |= 1 << 6 - self.ephemeral = True - if content: - message_data["content"] = content - if embeds: - message_data["embeds"] = [embed.to_dict() for embed in embeds] - if allowed_mentions: - message_data["allowed_mentions"] = allowed_mentions.to_dict() - if components: - message_data["components"] = [ - component.to_dict() for component in components - ] - if attachments: - message_data["attachments"] = [ - attachment.to_dict() for attachment in attachments - ] - - payload = {"type": 4, "data": message_data} - await self.client.http.post( - f"/interactions/{self.id}/{self.token}/callback", json=payload - ) - - async def defer(self, *, ephemeral: bool = False): - await self.client.http.post( - f"/interaction/{self.id}/{self.token}/callback", - json={"type": 6, "data": {"flags": 1 << 6 if ephemeral else 0}}, - ) - - async def send_modal(self, modal: Modal): - from EpikCord import Modal - - if not isinstance(modal, Modal): - raise InvalidArgumentType("The modal argument must be of type Modal.") - - payload = {"type": 9, "data": modal.to_dict()} - await self.client.http.post( - f"/interactions/{self.id}/{self.token}/callback", json=payload - ) - - @property - def is_ping(self): - return self.type == 1 - - @property - def is_application_command(self): - return self.type == 2 - - @property - def is_message_component(self): - return self.type == 3 - - @property - def is_autocomplete(self): - return self.type == 4 - - @property - def is_modal_submit(self): - return self.type == 5 - - async def fetch_original_response(self, *, skip_cache: Optional[bool] = False): - if not skip_cache and self.original_response: - return self.original_response - - message_data = await self.client.http.get( - f"/webhooks/{self.application_id}/{self.token}/messages/@original" - ) - self.original_response = Message(self.client, message_data) - return self.original_response - - async def edit_original_response( - self, - *, - tts: bool = False, - content: Optional[str] = None, - embeds: Optional[List[Embed]] = None, - allowed_mentions=None, - components: Optional[List[Union[Button, SelectMenu, TextInput]]] = None, - attachments: Optional[List[Attachment]] = None, - suppress_embeds: Optional[bool] = False, - ephemeral: Optional[bool] = False, - ) -> Message: - - message_data: MessagePayload = {"tts": tts, "flags": 0} - - if suppress_embeds: - message_data["flags"] += 1 << 2 - if ephemeral: - message_data["flags"] += 1 << 6 - - if content: - message_data["content"] = content - if embeds: - message_data["embeds"] = [embed.to_dict() for embed in embeds] - if allowed_mentions: - message_data["allowed_mentions"] = allowed_mentions.to_dict() - if components: - message_data["components"] = [ - component.to_dict() for component in components - ] - if attachments: - message_data["attachments"] = [ - attachment.to_dict() for attachment in attachments - ] - - new_message_data = await self.client.http.patch( - f"/webhooks/{self.application_id}/{self.token}/messages/@original", - json=message_data, - ) - self.original_response = Message(self.client, new_message_data) - return self.original_response - - async def delete_original_response(self): - await self.client.http.delete( - f"/webhooks/{self.application_id}/{self.token}/messages/@original" - ) - - async def create_followup( - self, - *, - tts: bool = False, - content: Optional[str] = None, - embeds: Optional[List[Embed]] = None, - allowed_mentions=None, - components: Optional[List[Union[Button, SelectMenu, TextInput]]] = None, - attachments: Optional[List[Attachment]] = None, - suppress_embeds: Optional[bool] = False, - ephemeral: Optional[bool] = False, - ) -> Message: - - message_data: MessagePayload = {"tts": tts, "flags": 0} - - if suppress_embeds: - message_data["flags"] += 1 << 2 - if ephemeral: - message_data["flags"] += 1 << 6 - - if content: - message_data["content"] = content - if embeds: - message_data["embeds"] = [embed.to_dict() for embed in embeds] - if allowed_mentions: - message_data["allowed_mentions"] = allowed_mentions.to_dict() - if components: - message_data["components"] = [ - component.to_dict() for component in components - ] - if attachments: - message_data["attachments"] = [ - attachment.to_dict() for attachment in attachments - ] - - response = await self.client.http.post( - f"/webhooks/{self.application_id}/{self.token}", json=message_data - ) - new_message_data = await response.json() - self.followup_response = Message(self.client, new_message_data) - return self.followup_response - - async def edit_followup( - self, - *, - tts: bool = False, - content: Optional[str] = None, - embeds: Optional[List[Embed]] = None, - allowed_mentions=None, - components: Optional[List[Union[Button, SelectMenu, TextInput]]] = None, - attachments: Optional[List[Attachment]] = None, - suppress_embeds: Optional[bool] = False, - ephemeral: Optional[bool] = False, - ) -> None: - - message_data: MessagePayload = {"tts": tts, "flags": 0} - - if suppress_embeds: - message_data["flags"] += 1 << 2 - if ephemeral: - message_data["flags"] += 1 << 6 - - if content: - message_data["content"] = content - if embeds: - message_data["embeds"] = [embed.to_dict() for embed in embeds] - if allowed_mentions: - message_data["allowed_mentions"] = allowed_mentions.to_dict() - if components: - message_data["components"] = [ - component.to_dict() for component in components - ] - if attachments: - message_data["attachments"] = [ - attachment.to_dict() for attachment in attachments - ] - - await self.client.http.patch( - f"/webhook/{self.application_id}/{self.token}/", json=message_data - ) - - async def delete_followup(self): - return await self.client.http.delete( - f"/webhook/{self.application_id}/{self.token}/" - ) - - -class BaseSlashCommandOption: - def __init__( - self, - *, - name: str, - description: str, - required: bool = False, - value: Optional[str] = None, - ): - self.name = name - self.description = description - self.required = required - self.value = value - self.type: Optional[int] = None - # ! Needs to be set by the subclass - # ! People shouldn't use this class, this is just a base class for other - # ! options, but they can use this for other options we are yet to account for. - - def to_dict(self): - return { - "name": self.name, - "description": self.description, - "required": self.required, - "type": self.type, - } - - -__all__ = ( - "Messageable", - "BaseCommand", - "TypingContextManager", - "Connectable", - "BaseComponent", - "BaseSlashCommandOption", -) diff --git a/EpikCord/application/__init__.py b/EpikCord/application/__init__.py deleted file mode 100644 index 8dcb40f2..00000000 --- a/EpikCord/application/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .application import * -from .application_command import * -from .team import * diff --git a/EpikCord/application/application.py b/EpikCord/application/application.py deleted file mode 100644 index e0480a51..00000000 --- a/EpikCord/application/application.py +++ /dev/null @@ -1,68 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, List, Optional - -from ..partials import PartialUser -from ..user import User -from .team import Team - -if TYPE_CHECKING: - import discord_typings - - from ..client import Client - - -def _filter_values(dictionary: dict) -> dict: - return {k: v for k, v in dictionary.items() if v is not None} - - -class Application: - def __init__(self, data: discord_typings.ApplicationData): - self.id: int = int(data["id"]) - self.name: str = data["name"] - self.icon: Optional[str] = data.get("icon") - self.description: str = data["description"] - self.rpc_origins: Optional[List[str]] = data.get("rpc_origins") - self.bot_public: bool = data["bot_public"] - self.bot_require_code_grant: bool = data["bot_require_code_grant"] - self.terms_of_service_url: Optional[str] = data.get("terms_of_service") # type: ignore - self.privacy_policy_url: Optional[str] = data.get("privacy_policy") # type: ignore - self.owner: Optional[PartialUser] = ( - PartialUser(data["owner"]) if "user" in data else None - ) - self.verify_key: str = data["verify_key"] - self.team: Optional[Team] = Team(data["team"]) if data["team"] else None - self.cover_image: Optional[str] = data.get("cover_image") - self.flags: Optional[int] = data.get("flags") - - def to_dict(self): - return _filter_values( - { - "id": self.id, - "name": self.name, - "description": self.description, - "rpc_origins": self.rpc_origins, - "bot_public": self.bot_public, - "bot_require_code_grant": self.bot_require_code_grant, - "terms_of_service_url": self.terms_of_service_url, - "privacy_policy_url": self.privacy_policy_url, - "verify_key": self.verify_key.to_dict(), - "team": self.team.to_dict() if self.team else None, - "cover_image": self.cover_image, - "flags": self.flags, - } - ) # A dict of things that aren't private and return a Truthy value. - - -class IntegrationApplication: - def __init__( - self, client: Client, data: discord_typings.IntegrationApplicationData - ): - self.id: int = int(data["id"]) - self.name: str = data["name"] - self.icon: Optional[str] = data.get("icon") - self.description: str = data["description"] - self.bot: Optional[User] = User(client, data["bot"]) if data["bot"] else None - - -__all__ = ("Application", "IntegrationApplication") diff --git a/EpikCord/application/application_command.py b/EpikCord/application/application_command.py deleted file mode 100644 index 557445a4..00000000 --- a/EpikCord/application/application_command.py +++ /dev/null @@ -1,101 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, List, Optional - -from ..flags import Permissions -from ..localizations import Locale, Localization -from ..options import * -from ..type_enums import ApplicationCommandPermissionType - -if TYPE_CHECKING: - import discord_typings - - -class ApplicationCommand: - def __init__(self, data: discord_typings.ApplicationCommandData): - self.id: int = int(data["id"]) - self.type: int = data["type"] - self.application_id: int = int(data["application_id"]) - self.guild_id: Optional[int] = ( - int(data["guild_id"]) if data.get("guild_id") else None - ) - self.name: str = data["name"] - self.description: str = data["description"] - conversion_type = { - 1: Subcommand, - 2: SubCommandGroup, - 3: StringOption, - 4: IntegerOption, - 5: BooleanOption, - 6: UserOption, - 7: ChannelOption, - 8: RoleOption, - 9: MentionableOption, - 10: NumberOption, - 11: AttachmentOption, - } - self.options: Optional[List[AnyOption]] = ( - [ - conversion_type[option["type"]](**option) for option in data["options"] # type: ignore - ] - if data.get("options") - else None - ) - self.default_member_permissions: Optional[Permissions] = ( - Permissions(int(data["default_member_permissions"])) # type: ignore - if data.get("default_member_permissions") - else None - ) - self.version: int = int(data["version"]) - self.name_localizations: Optional[List[Localization]] = [Localization(Locale(k), v) for (k, v) in data["name_localizations"].items()] if data.get("name_localizations") else None # type: ignore - self.description_localizations: Optional[List[Localization]] = ( - [ - Localization(Locale(k), v) for k, v in data["description_localizations"].items() # type: ignore - ] - if data.get("description_localizations") - else None - ) - self.name_localisations = self.name_localizations - self.description_localisations = self.description_localizations - - -class ApplicationCommandPermission: - def __init__(self, data: discord_typings.ApplicationCommandPermissionsData): - self.id: int = int(data["id"]) - self.type: ApplicationCommandPermissionType = ApplicationCommandPermissionType( - data["type"] - ) - self.permission: bool = data["permission"] - - def to_dict(self) -> discord_typings.ApplicationCommandPermissionsData: - return { - "id": self.id, - "type": self.type.value, - "permission": self.permission, - } - - -class GuildApplicationCommandPermission: - def __init__(self, data: discord_typings.GuildApplicationCommandPermissionData): - self.id: int = int(data["id"]) - self.application_id: int = int(data["application_id"]) - self.guild_id: int = int(data["guild_id"]) - self.permissions: List[ApplicationCommandPermission] = [ - ApplicationCommandPermission(permissions) - for permissions in data["permissions"] - ] - - def to_dict(self): - return { - "id": self.id, - "application_id": self.application_id, - "guild_id": self.guild_id, - "permissions": self.permissions.to_dict(), - } - - -__all__ = ( - "ApplicationCommand", - "GuildApplicationCommandPermission", - "ApplicationCommandPermission", -) diff --git a/EpikCord/application/team.py b/EpikCord/application/team.py deleted file mode 100644 index 8e364e52..00000000 --- a/EpikCord/application/team.py +++ /dev/null @@ -1,29 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, List, Optional - -from ..partials import PartialUser - -if TYPE_CHECKING: - import discord_typings - - -class TeamMember: - def __init__(self, data: discord_typings.TeamMemberData): - self.data = data - self.membership_state: int = data["membership_state"] - self.team_id: int = int(data["team_id"]) - self.user: PartialUser = PartialUser(data["user"]) - - -class Team: - def __init__(self, data: discord_typings.TeamData): - self.data = data - self.icon: Optional[str] = data.get("icon") - self.id: int = int(data["id"]) - self.members: List[TeamMember] = [ - TeamMember(m) for m in data.get("members", []) - ] - - -__all__ = ("Team", "TeamMember") diff --git a/EpikCord/auto_moderation.py b/EpikCord/auto_moderation.py deleted file mode 100644 index 12d29769..00000000 --- a/EpikCord/auto_moderation.py +++ /dev/null @@ -1,150 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Dict, List, Optional, TypedDict, Union - -from typing_extensions import NotRequired - -from .type_enums import ( - AutoModActionType, - AutoModEventType, - AutoModKeywordPresetType, - AutoModTriggerType, -) - -if TYPE_CHECKING: - import discord_typings - - -class AutoModTriggerMetadata: - def __init__(self, data: discord_typings.AutoModerationTriggerMetadataData): - self.keyword_filter: Optional[List[str]] = ( - data["keyword_filter"] if data.get("keyword_filter") else None - ) - self.presets: Optional[List[AutoModKeywordPresetType]] = ( - [AutoModKeywordPresetType(x) for x in data["presets"]] - if data.get("presets") - else None - ) - - def to_dict(self): - return { - "keyword_filter": self.keyword_filter, - "presets": [int(preset) for preset in self.presets], - } - - -class AutoModActionMetadata: - def __init__(self, data: discord_typings.AutoModerationActionData): - self.channel_id: int = int(data["channel_id"]) - self.duration_seconds: int = data["duration_seconds"] - - def to_dict(self): - return { - "channel_id": self.channel_id, - "duration_seconds": self.duration_seconds, - } - - -class AutoModAction: - def __init__(self, data: discord_typings.AutoModerationActionData): - self.type: int = AutoModActionType(data["type"]) - self.metadata = AutoModActionMetadata(data["metadata"]) - - def to_dict(self): - return { - "type": int(self.type), - "metadata": self.metadata.to_dict(), - } - - -class AutoModRulePayload(TypedDict): - name: NotRequired[str] - event_type: NotRequired[discord_typings.AutoModerationEventTypes] - trigger_metadata: NotRequired[discord_typings.AutoModerationTriggerMetadataData] - actions: NotRequired[List[discord_typings.AutoModerationActionData]] - enabled: NotRequired[bool] - exempt_roles: NotRequired[List[int]] - exempt_channels: NotRequired[List[int]] - - -class AutoModRule: - def __init__(self, client, data: discord_typings.AutoModerationRuleData): - self.client = client - self.id: int = int(data["id"]) - self.guild_id: int = int(data["guild_id"]) - self.name: str = data["name"] - self.creator_id: int = int(data["creator_id"]) - self.event_type = AutoModEventType(data["event_type"]) - self.trigger_type: Optional[AutoModTriggerType] = ( - AutoModTriggerType(data["trigger_type"]) - if data.get("trigger_type") - else None - ) - self.trigger_metadata: List[AutoModTriggerMetadata] = [ - AutoModTriggerMetadata(d) for d in data["trigger_metadata"] - ] - self.actions: List[AutoModAction] = [ - AutoModAction(data) for data in data["actions"] - ] - self.enabled: bool = data["enabled"] - self.exempt_roles_ids: List[int] = ( - [int(role) for role in data["exempt_roles"]] - if data.get("exempt_roles") - else [] - ) - self.except_channels_ids: List[int] = ( - [int(channel) for channel in data["exempt_channels"]] - if data.get("exempt_channels") - else [] - ) - - async def edit( - self, - *, - name: Optional[str] = None, - event_type: Optional[discord_typings.AutoModerationEventTypes] = None, - trigger_metadata: Optional[AutoModTriggerMetadata] = None, - actions: Optional[List[AutoModAction]] = None, - enabled: Optional[bool] = None, - exempt_roles: Optional[List[int]] = None, - exempt_channels: Optional[List[int]] = None, - ): - payload: AutoModRulePayload = {} - - if name: - payload["name"] = name - - if event_type: - payload["event_type"] = event_type - - if enabled is not None: - payload["enabled"] = enabled - - if exempt_channels: - payload["exempt_channels"] = exempt_channels - - if exempt_roles: - payload["exempt_roles"] = exempt_roles - - if trigger_metadata is not None: - payload["trigger_metadata"] = trigger_metadata.to_dict() - - if actions: - payload["actions"] = [action.to_dict() for action in actions] - - await self.client.http.patch( - f"/guilds/{self.guild_id}/auto-moderation/rules/{self.id}", json=payload - ) - - async def delete(self): - await self.client.http.delete( - f"guilds/{self.guild_id}/auto-moderation/rules/{self.id}" - ) - - -__all__ = ( - "AutoModTriggerMetadata", - "AutoModActionMetadata", - "AutoModAction", - "AutoModRule", -) diff --git a/EpikCord/bin/COPYING b/EpikCord/bin/COPYING deleted file mode 100644 index 4c2ba2be..00000000 --- a/EpikCord/bin/COPYING +++ /dev/null @@ -1,28 +0,0 @@ -Copyright (c) 1994-2013 Xiph.Org Foundation and contributors - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions -are met: - -- Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - -- Redistributions in binary form must reproduce the above copyright -notice, this list of conditions and the following disclaimer in the -documentation and/or other materials provided with the distribution. - -- Neither the name of the Xiph.Org Foundation nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION -OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/EpikCord/bin/libopus-0.x64.dll b/EpikCord/bin/libopus-0.x64.dll deleted file mode 100644 index 74a8e3554ff00d07d979d7763f03555d8e392f21..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 441856 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%0DfdGRmjsdzH`o8kKLF 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

?{OTS8Mq@SdJN#9A| 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Ν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 B;I9Ax diff --git a/EpikCord/bin/libopus-0.x86.dll b/EpikCord/bin/libopus-0.x86.dll deleted file mode 100644 index ee71317fa6291e8a015b57b5b42a968b49ceb7fd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 366080 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

ss8*YJa`<&!F4OE!PF< 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 None: - - response = await self.client.http.delete( - f"/channels/{self.id}", channel_id=self.id, reason=reason - ) - return await response.json() - - async def fetch_invites(self): - response = await self.client.http.get( - f"/channels/{self.id}/invites", channel_id=self.id - ) - return await response.json() - - async def create_invite( - self, - *, - max_age: Optional[int] = None, - max_uses: Optional[int] = None, - temporary: Optional[bool] = None, - unique: Optional[bool] = None, - target_type: Optional[int] = None, - target_user_id: Optional[str] = None, - target_application_id: Optional[str] = None, - ): - data = self.client.utils.filter_values( - { - "max_age": max_age, - "max_uses": max_uses, - "temporary": temporary, - "unique": unique, - "target_type": target_type, - "target_user_id": target_user_id, - "target_application_id": target_application_id, - } - ) - - await self.client.http.post( - f"/channels/{self.id}/invites", json=data, channel_id=self.id - ) - - async def delete_overwrite(self, overwrites) -> None: - response = await self.client.http.delete( - f"/channels/{self.id}/permissions/{overwrites.id}", channel_id=self.id - ) - return await response.json() - - -class CommonFieldsTextAndNews(Messageable): - def __init__( - self, - client: Client, - data: Union[discord_typings.NewsChannelData, discord_typings.TextChannelData], - ): - super().__init__(client, int(data["id"])) - self.topic: Optional[str] = data["topic"] - self.parent_id: Optional[int] = ( - int(data["parent_id"]) if data.get("parent_id") else None - ) - self.last_pin_timestamp: Optional[datetime.datetime] = datetime.datetime.fromisoformat(data["last_pin_timestamp"]) if data.get("last_pin_timestamp") else None # type: ignore - self.default_auto_archive_duration: Optional[int] = data.get( - "default_auto_archive_duration" - ) - self.flags: ChannelFlags = ChannelFlags(data["flags"]) - - -class GuildTextChannel(BaseGuildChannel, CommonFieldsTextAndNews): - def __init__(self, client: Client, data: discord_typings.TextChannelData): - super().__init__(client, data) - CommonFieldsTextAndNews.__init__(self, client, data) - self.rate_limit_per_user: int = data["rate_limit_per_user"] - - -class NewsChannel(BaseGuildChannel, CommonFieldsTextAndNews): - def __init__(self, client: Client, data: discord_typings.NewsChannelData): - super().__init__(client, data) - CommonFieldsTextAndNews.__init__(self, client, data) - - -GuildAnnouncementChannel = NewsChannel - - -class DMChannel(Messageable): - def __init__(self, client: Client, data: discord_typings.DMChannelData): - super().__init__(client, int(data["id"])) - self.recipients: List[User] = [ - User(client, user) for user in data["recipients"] - ] - self.last_pin_timestamp: Optional[datetime.datetime] = datetime.datetime.fromisoformat(data["last_pin_timestamp"]) if data.get("last_pin_timestamp") else None # type: ignore - self.flags: ChannelFlags = ChannelFlags(data["flags"]) - - -class GroupDMChannel(Messageable): - def __init__(self, client: Client, data: discord_typings.GroupDMChannelData): - super().__init__(client, int(data["id"])) - self.name: str = data["name"] - self.recipients: List[User] = [ - User(client, user) for user in data["recipients"] - ] - self.icon: Optional[str] = data["icon"] - self.owner_id: int = int(data["owner_id"]) - self.application_id: Optional[int] = ( - int(data["application_id"]) if data["application_id"] else None - ) - self.last_pin_timestamp: Optional[datetime.datetime] = datetime.datetime.fromisoformat(data["last_pin_timestamp"]) if data.get("last_pin_timestamp") else None # type: ignore - self.flags: ChannelFlags = ChannelFlags(data["flags"]) - - -class VoiceChannel(BaseGuildChannel, Connectable): - def __init__(self, client: Client, data: discord_typings.VoiceChannelData): - super().__init__(client, data) - Connectable.__init__(self, client, channel=self) - self.bitrate: int = data["bitrate"] - self.user_limit: int = data["user_limit"] - self.parent_id: Optional[int] = ( - int(data["parent_id"]) if data["parent_id"] else None - ) - self.last_pin_timestamp: Optional[datetime.datetime] = datetime.datetime.fromisoformat(data["last_pin_timestamp"]) if data.get("last_pin_timestamp") else None # type: ignore - self.rtc_region: Optional[str] = data["rtc_region"] - self.video_quality_mode: Optional[int] = data.get("video_quality_mode") - self.flags: ChannelFlags = ChannelFlags(data["flags"]) - - -class CategoryChannel(BaseGuildChannel): - def __init__(self, client: Client, data: discord_typings.CategoryChannelData): - self.flags: ChannelFlags = ChannelFlags(data["flags"]) - - -class ForumChannel(BaseGuildChannel): - def __init__(self, client: Client, data: discord_typings.ForumChannelData): - super().__init__(client, data) - self.topic: Optional[str] = data["topic"] - self.rate_limit_per_user: int = data["rate_limit_per_user"] - self.default_auto_archive_duration: Optional[int] = data.get( - "default_auto_archive_duration" - ) - self.flags: ChannelFlags = ChannelFlags(data["flags"]) - self.default_reaction_emoji: Optional[ - discord_typings.DefaultReactionData - ] = data.get("default_reaction_emoji") - self.default_thread_rate_limit_per_user: Optional[int] = data.get( - "default_thread_rate_limit_per_user" - ) - self.default_sort_order: Optional[discord_typings.SortOrderTypes] = data.get( - "default_sort_order" - ) - - -class GuildStageChannel(BaseGuildChannel, Connectable): - def __init__( - self, - client: Client, - data: Union[ - discord_typings.VoiceChannelData, discord_typings.InviteStageInstanceData - ], - ): - super().__init__(client, data) - Connectable.__init__(self, client, channel=self) - self.bitrate: int = data["bitrate"] - self.user_limit: int = data["user_limit"] - self.parent_id: Optional[int] = ( - int(data["parent_id"]) if data["parent_id"] else None - ) - self.last_pin_timestamp: Optional[datetime.datetime] = datetime.datetime.fromisoformat(data["last_pin_timestamp"]) if data.get("last_pin_timestamp") else None # type: ignore - self.rtc_region: Optional[str] = data["rtc_region"] - self.video_quality_mode: Optional[int] = data.get("video_quality_mode") - - -AnyChannel = Union[ - GuildTextChannel, - VoiceChannel, - CategoryChannel, - NewsChannel, - Thread, - GuildStageChannel, - ForumChannel, - GroupDMChannel, -] - -__all__ = ( - "Overwrite", - "GuildTextChannel", - "GuildAnnouncementChannel", - "DMChannel", - "CategoryChannel", - "GuildStageChannel", - "VoiceChannel", - "ForumChannel", - "NewsChannel", - "AnyChannel", -) diff --git a/EpikCord/client/__init__.py b/EpikCord/client/__init__.py deleted file mode 100644 index 73d19696..00000000 --- a/EpikCord/client/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from .client import * -from .client_application import * -from .client_user import * -from .command_handler import * -from .http_client import * -from .sections import * -from .user_client import * -from .websocket_client import * diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py deleted file mode 100644 index faa66d16..00000000 --- a/EpikCord/client/client.py +++ /dev/null @@ -1,82 +0,0 @@ -from __future__ import annotations - -from logging import getLogger -from typing import ( - TYPE_CHECKING, - Any, - Callable, - Coroutine, - Dict, - List, - Optional, - Type, - Union, -) - -from ..flags import Intents -from ..sticker import Sticker, StickerPack -from .command_handler import CommandHandler -from .websocket_client import WebsocketClient - -if TYPE_CHECKING: - import discord_typings - - from EpikCord import Presence, Section - -logger = getLogger(__name__) - -Callback = Callable[..., Coroutine[Any, Any, Any]] - - -class Client(WebsocketClient, CommandHandler): - def __init__( - self, - token: str, - intents: Union[Intents, int] = 0, - *, - discord_endpoint: str = "https://discord.com/api/v10", - presence: Optional[Presence] = None, - ): - super().__init__(token, intents, presence, discord_endpoint=discord_endpoint) - CommandHandler.__init__(self) - from EpikCord import Utils - - self._components: Dict[str, Callback] = {} - self.utils = Utils(self) - - self.sections: List[Section] = [] - - def load_section(self, section_class: Section): - section = section_class(self) # type: ignore - - for event in section._events.values(): - self.events[event.name] = event.callback - - for command in section._commands.values(): - - self.commands[command.name] = command - - async def fetch_sticker(self, sticker_id: str) -> Sticker: - response = await self.http.get(f"/stickers/{sticker_id}") - json = await response.json() - return Sticker(self, json) # TODO: Possibly cache this? - - async def list_nitro_sticker_packs(self) -> List[StickerPack]: - response = await self.http.get("/sticker-packs") - json = await response.json() - return [StickerPack(self, pack) for pack in json["sticker_packs"]] - - async def _interaction_create(self, data: discord_typings.InteractionCreateData): - await super()._interaction_create(data) - interaction = self.utils.interaction_from_type(data) - await self.handle_interaction(interaction) - - def component(self, custom_id: str): - def wrapper(func): - self._components[custom_id] = func - return func - - return wrapper - - -__all__ = ("Client",) diff --git a/EpikCord/client/client_application.py b/EpikCord/client/client_application.py deleted file mode 100644 index 2c504e1c..00000000 --- a/EpikCord/client/client_application.py +++ /dev/null @@ -1,292 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Union - -from ..application import ( - Application, - ApplicationCommand, - ApplicationCommandPermission, - GuildApplicationCommandPermission, -) -from ..exceptions import ( - InvalidApplicationCommandOptionType, - InvalidApplicationCommandType, -) -from ..flags import Permissions -from ..options import * - -if TYPE_CHECKING: - import discord_typings - - from .client import Client, WebsocketClient - - -class ClientApplication(Application): - def __init__( - self, - client: WebsocketClient, - data: discord_typings.ApplicationData, - ): - super().__init__(data) - self.client = client - self.commands: List[ApplicationCommand] = [] - - async def fetch(self): - response = await self.client.http.get("oauth2/applications/@me") - data: dict = await response.json() - return Application(data) - - async def fetch_global_application_commands( - self, *, with_localizations: bool = False, with_localisations: bool = False - ) -> List[ApplicationCommand]: - with_localisation = with_localisations or with_localizations - response = await self.client.http.get( - f"/applications/{self.id}/commands?with_localizations={with_localisation}" - ) - payload = [ApplicationCommand(command) for command in await response.json()] - self.commands = payload - return payload - - async def create_global_application_command( - self, - *, - name: str, - description: str, - options: List[AnyOption] = [], - default_member_permissions: Optional[Permissions] = None, - command_type: Literal[1, 2, 3] = 1, - dm_permission: bool = True, - ): - payload: discord_typings.ApplicationCommandPayload = { - "name": name, - "description": description, - "dm_permission": dm_permission, - } - - if default_member_permissions: - payload["default_member_permissions"] = ( - str(default_member_permissions.value) - if isinstance(default_member_permissions, Permissions) - else default_member_permissions - ) - - if command_type not in range(1, 4): - raise InvalidApplicationCommandType("Command type must be 1, 2, or 3.") - - payload["type"] = command_type - - for option in options: - if not isinstance( - option, - ( - Subcommand, - SubCommandGroup, - StringOption, - IntegerOption, - BooleanOption, - UserOption, - ChannelOption, - RoleOption, - MentionableOption, - NumberOption, - AttachmentOption, - ), - ): - raise InvalidApplicationCommandOptionType( - f"Options must be of type Subcommand, SubCommandGroup, " - f"StringOption, IntegerOption, BooleanOption, UserOption, " - f"ChannelOption, RoleOption, MentionableOption, " - f"NumberOption, not {option.__class__}. " - ) - - response = await self.client.http.post( - f"/applications/{self.id}/commands", json=payload - ) - return ApplicationCommand(await response.json()) - - async def fetch_application_command(self, command_id: str): - response = await self.client.http.get( - f"/applications/{self.id}/commands/{command_id}" - ) - return ApplicationCommand(await response.json()) - - async def edit_global_application_command( - self, - command_id: str, - *, - name: Optional[str] = None, - description: Optional[str] = None, - options: Optional[List[AnyOption]] = None, - default_member_permissions: Optional[Permissions] = None, - ): - payload: Dict[ - str, - Optional[ - Union[List[discord_typings.ApplicationCommandOptionData], str, int] - ], - ] = { - "name": name, - "description": description, - } - if options: - payload["options"] = [option.to_dict() for option in options] - if default_member_permissions: - payload["default_member_permissions"] = ( - default_member_permissions.value - if isinstance(default_member_permissions, Permissions) - else default_member_permissions - ) - - await self.client.http.patch( - f"/applications/{self.id}/commands/{command_id}", json=payload - ) - - async def delete_global_application_command(self, command_id: str): - await self.client.http.delete(f"/applications/{self.id}/commands/{command_id}") - - async def bulk_overwrite_global_application_commands(self, commands: List[Dict]): - await self.client.http.put(f"/applications/{self.id}/commands", json=commands) - - async def fetch_guild_application_commands(self, guild_id: str): - response = await self.client.http.get( - f"/applications/{self.id}/guilds/{guild_id}/commands" - ) - return [ApplicationCommand(command) for command in await response.json()] - - async def create_guild_application_command( - self, - guild_id: str, - *, - name: str, - description: str, - options=None, - default_member_permission: Optional[Permissions] = None, - command_type: discord_typings.ApplicationCommandTypes = 1, - ): - if options is None: - options = [] - - payload: discord_typings.ApplicationCommandPayload = { - "name": name, - "description": description, - } - - if default_member_permission: - payload["default_member_permissions"] = ( - str(default_member_permission.value) - if isinstance(default_member_permission, Permissions) - else default_member_permission - ) - - if command_type not in range(1, 4): - raise InvalidApplicationCommandType("Command type must be 1, 2, or 3.") - - payload["type"] = command_type - - for option in options: - if not isinstance( - option, - ( - Subcommand, - SubCommandGroup, - StringOption, - IntegerOption, - BooleanOption, - UserOption, - ChannelOption, - RoleOption, - MentionableOption, - NumberOption, - AttachmentOption, - ), - ): - raise InvalidApplicationCommandOptionType( - f"Options must be of type Subcommand, SubCommandGroup, " - f"StringOption, IntegerOption, BooleanOption, UserOption, " - f"ChannelOption, RoleOption, MentionableOption, " - f"NumberOption, not {option.__class__}. " - ) - - response = await self.client.http.post( - f"/applications/{self.id}/guilds/{guild_id}/commands", json=payload - ) - return ApplicationCommand(await response.json()) - - async def fetch_guild_application_command(self, guild_id: str, command_id: str): - response = await self.client.http.get( - f"/applications/{self.id}/guilds/{guild_id}/commands/{command_id}" - ) - return ApplicationCommand(await response.json()) - - async def edit_guild_application_command( - self, - guild_id: str, - command_id: str, - *, - name: Optional[str] = None, - description: Optional[str] = None, - options: Optional[List[AnyOption]] = None, - default_member_permissions: Optional[Permissions] = None, - ): - payload: Dict[ - str, - Optional[ - Union[List[discord_typings.ApplicationCommandOptionData], str, int] - ], - ] = {} - if name: - payload["name"] = name - if description: - payload["description"] = description - if options: - payload["options"] = [option.to_dict() for option in options] - if default_member_permissions: - payload["default_member_permissions"] = ( - str(default_member_permissions.value) - if isinstance(default_member_permissions, Permissions) - else default_member_permissions - ) - - await self.client.http.patch( - f"/applications/{self.id}/guilds/{guild_id}/commands/{command_id}", - json=payload, - ) - - async def delete_guild_application_command(self, guild_id: str, command_id: str): - await self.client.http.delete( - f"/applications/{self.id}/guilds/{guild_id}/commands/{command_id}" - ) - - async def bulk_overwrite_guild_application_commands( - self, guild_id: str, commands: List[Dict] - ): - await self.client.http.put( - f"/applications/{self.id}/guilds/{guild_id}/commands", json=commands - ) - - async def fetch_guild_application_command_permissions( - self, guild_id: str, command_id: str - ): - response = await self.client.http.get( - f"/applications/{self.id}/guilds/{guild_id}/commands/{command_id}/permissions" - ) - return [ - GuildApplicationCommandPermission(command) - for command in await response.json() - ] - - async def edit_application_command_permissions( - self, - guild_id: str, - command_id, - *, - permissions: List[ApplicationCommandPermission], - ): - payload = [permission.to_dict() for permission in permissions] - await self.client.http.put( - f"/applications/{self.id}/guilds/{guild_id}/commands/{command_id}/permissions", - json=payload, - ) - - -__all__ = ("ClientApplication",) diff --git a/EpikCord/client/client_user.py b/EpikCord/client/client_user.py deleted file mode 100644 index 3b341b2e..00000000 --- a/EpikCord/client/client_user.py +++ /dev/null @@ -1,43 +0,0 @@ -from __future__ import annotations - -from logging import getLogger -from typing import TYPE_CHECKING, Optional, Union - -from ..user import User - -if TYPE_CHECKING: - import discord_typings - - from .client import Client - from .websocket_client import WebsocketClient - -logger = getLogger(__name__) - - -class ClientUser(User): - def __init__(self, client: WebsocketClient, data: discord_typings.UserData): - super().__init__(client, data) - if not self.bot: # if they're a user account - logger.critical("Self botting is against Discord ToS. You can get banned. ") - exit(1) - - async def fetch(self): - response = await self.client.http.get("users/@me") - data = await response.json() - return ClientUser(self.client, data) - # Reinitialize the class with the new data. - - async def edit( - self, *, username: Optional[str] = None, avatar: Optional[bytes] = None - ): - payload = {} - if username: - payload["username"] = username - if avatar: - payload["avatar"] = self.client.utils.bytes_to_base64_data(avatar) - response = await self.client.http.patch("users/@me", json=payload) - data: discord_typings.UserData = await response.json() - return ClientUser(self.client, data) - - -__all__ = ("ClientUser",) diff --git a/EpikCord/client/command_handler.py b/EpikCord/client/command_handler.py deleted file mode 100644 index abb9b290..00000000 --- a/EpikCord/client/command_handler.py +++ /dev/null @@ -1,237 +0,0 @@ -from __future__ import annotations - -from inspect import iscoroutinefunction -from logging import getLogger -from typing import TYPE_CHECKING, Dict, List, Optional, Union - -from ..application import ApplicationCommand -from ..localizations import * -from ..options import AnyOption, ReceivedOption - -if TYPE_CHECKING: - from .. import Check - -logger = getLogger(__name__) - - -class CommandHandler: - def __init__(self): - from EpikCord import ClientMessageCommand, ClientSlashCommand, ClientUserCommand - - self.commands: Dict[ - str, Union[ClientSlashCommand, ClientUserCommand, ClientMessageCommand] - ] = {} - - def command( - self, - *, - name: Optional[str] = None, - description: str = None, - guild_ids: Optional[List[str]] = None, - options: Optional[List[AnyOption]] = None, - localized_names: Optional[List[Localization]] = None, - localized_descriptions: Optional[List[Localization]] = None, - checks: Optional[List[Check]] = None, - ): - name_localizations: List[Localization] = localized_names or [] - description_localization: List[Localization] = localized_descriptions or [] - - for name_localisation in localized_names: - if len(name_localisation.value) > 32: - raise TypeError( - f"Command with name {name} has too long of a name for locale {name_localisation.locale}. Must be less than 32 characters. It is {len(name_localisation.value)}/32." - ) - - for description_localisation in localized_descriptions: - if len(description_localisation.value) > 100: - raise TypeError(f"Command with name {name} has too long of a length") - - def register_slash_command(func): - from EpikCord import ClientSlashCommand - - name = name.lower() or func.__name__.lower() - desc = description or func.__doc__ - - if len(name) > 32: - raise TypeError( - f"Command with name {name} has too long of a name. Must be less than 32 characters. It is {len(name)}/32." - ) - - if not desc: - raise TypeError( - f"Command with name {name} has no description. This is required." - ) - - if len(desc) > 100: - raise TypeError( - f"Command with name {name} has too large of a description. It must be 100 or less characters. It is {len(desc)}/100" - ) - - command = ClientSlashCommand( - name=name, - description=desc, - guild_ids=guild_ids or [], - options=options or [], - callback=func, - name_localizations=localized_names, - description_localizations=localized_descriptions, - checks=checks or [], - ) - - self.commands[command.name] = command - return command - - return register_slash_command - - def user_command( - self, name: Optional[str] = None, checks: Optional[List[Check]] = None - ): - def register_slash_command(func): - from EpikCord import ClientUserCommand - - results = ClientUserCommand( - callback=func, name=name or func.__name__, checks=checks or [] - ) - - self.commands[name] = results - return results - - return register_slash_command - - def message_command( - self, name: Optional[str] = None, checks: Optional[List[Check]] = None - ): - def register_slash_command(func): - from EpikCord import ClientMessageCommand - - results = ClientMessageCommand( - callback=func, name=name or func.__name__, checks=checks or [] - ) - - self.commands[name] = results - return results - - return register_slash_command - - async def handle_interaction(self, interaction): - """The function which is the handler for interactions. - Change this if you want to, to change how your "command handler" works - - Parameters - ---------- - interaction: Union[ApplicationCommandInteraction, MessageComponentInteraction, AutoCompleteInteraction, ModalSubmitInteraction] - A subclass of BaseInteraction which represents the Interaction - """ - - if interaction.is_ping: - return await self.http.post( - f"interactions/{interaction.id}/{interaction.token}/callback", - json={"type": 1}, - ) - - elif interaction.is_application_command: - command = self.commands.get(interaction.command_name) - - if not command: - logger.warning( - f"Command {interaction.command_name} is not registered in " - f"this code, but is registered with Discord. " - ) - return # TODO Possibly add an error which people can handle? - - options = [] - - if command.is_user_command() or command.is_message_command(): - options.append(interaction.target_id) - - if command.is_slash_command(): - for check in command.checks: - if iscoroutinefunction(check.callback): - if not await check.callback(interaction): - return await check.failure_callback(interaction) - - elif not check.callback(interaction): - await check.failure_callback(interaction) - await check.success_callback(interaction) - - options.extend(ReceivedOption(option) for option in interaction.options) - - try: - return await command.callback( - interaction, *[option.value for option in options] - ) - except Exception as e: - await self.command_error(interaction, e) - - if interaction.is_message_component: # If it's a message component interaction - - if not self._components.get( - interaction.custom_id - ): # If it's registered with the bot - logger.warning( - f"A user tried to interact with a component with the " - f"custom id {interaction.custom_id}, but it is not " - f"registered in this code, but is on Discord. " - ) - - if interaction.is_button(): # If it's a button - component = None - for action_row in interaction.message.components: - for component in action_row.components: - if component.custom_id == interaction.custom_id: - component = component - - return await self._components[interaction.custom_id]( - interaction, component - ) # Call the callback - - elif interaction.is_select_menu(): - - if not self._components.get(interaction.custom_id): - logger.warning( - f"A user tried to interact with a component with the " - f"custom id {interaction.custom_id}, but it is not " - f"registered in this code, but is on Discord. " - ) - return - - component = None - for action_row in interaction.message.components: - for possible_component in action_row.components: - if possible_component.custom_id == interaction.custom_id: - component = possible_component - break - - return await self._components[interaction.custom_id]( - interaction, component, *interaction.values - ) - - if interaction.is_autocomplete: - command = self.commands.get(interaction.command_name) - if not command: - return - try: - option = list( - filter(lambda option: option.focused == True, interaction.options) - )[0] - except IndexError: - logger.warning( - f"No option was focused for {interaction.command_name} but we still received an autocomplete interaction." - ) - if auto_complete_callback := command.autocomplete_options.get(option): - await auto_complete_callback(interaction, ReceivedOption(option)) - - if interaction.is_modal_submit: - action_rows = interaction._components - component_object_list = [] - for action_row in action_rows: - component_object_list.extend( - component["value"] for component in action_row.get("components") - ) - - await self._components.get(interaction.custom_id)( - interaction, *component_object_list - ) - - -__all__ = ("CommandHandler",) diff --git a/EpikCord/client/http_client.py b/EpikCord/client/http_client.py deleted file mode 100644 index eba5cfd9..00000000 --- a/EpikCord/client/http_client.py +++ /dev/null @@ -1,312 +0,0 @@ -from __future__ import annotations - -import asyncio -import contextlib -import zlib -from functools import partialmethod -from importlib.util import find_spec -from logging import getLogger -from typing import TYPE_CHECKING, Any, Dict, Optional, Union - -from aiohttp import ClientSession, ClientWebSocketResponse - -from ..exceptions import ( - DiscordAPIError, - DiscordServerError5xx, - Forbidden403, - NotFound404, -) -from ..status_code import HTTPCodes - -logger = getLogger(__name__) - -_ORJSON = find_spec("orjson") - - -if _ORJSON: - import orjson as json # type: ignore - -else: - import json # type: ignore - -if TYPE_CHECKING: - import discord_typings - - -class _FakeTask: - def cancel(self): - return True - - -class UnknownBucket: - def __init__(self): - self.event = asyncio.Event() - self.event.set() - self.close_task: Union[_FakeTask, asyncio.Task] = _FakeTask() - - -class Bucket(UnknownBucket): - def __init__(self, *, discord_hash: str): - super().__init__() - self.bucket_hash = discord_hash - - def __eq__(self, other): - return self.bucket_hash == other.bucket_hash - - def __hash__(self): - return id(self) - - -class DiscordWSMessage: - def __init__(self, *, data, type, extra): - self.data = data - self.type = type - self.extra = extra - - def json(self) -> Any: - return json.loads(self.data) - - -class GatewayWebsocket(ClientWebSocketResponse): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.buffer: bytearray = bytearray() - self.inflator = zlib.decompressobj() - - async def receive(self, *args, **kwargs): - ws_message = await super().receive(*args, **kwargs) - message = ws_message.data - - if isinstance(message, bytes): - - self.buffer.extend(message) - - if len(message) < 4 or message[-4:] != b"\x00\x00\xff\xff": - return - - message = self.inflator.decompress(self.buffer) - - message = message.decode("utf-8") - self.buffer: bytearray = bytearray() - - return DiscordWSMessage( - data=message, type=ws_message.type, extra=ws_message.extra - ) - - -class HTTPClient: - def __init__(self, token: Optional[str] = None, *args, **kwargs): - from EpikCord import __version__ - - self.base_uri: str = kwargs.pop( - "discord_endpoint", "https://discord.com/api/v10" - ) - - headers = { - "User-Agent": f"DiscordBot (https://github.com/EpikCord/EpikCord.py {__version__})", - "Content-Type": "application/json", - } - - if token: - headers["Authorization"] = f"Bot {token}" - - self.session = ClientSession( - *args, - **kwargs, - json_serialize=lambda x, *__, **___: json.dumps(x).decode("utf-8") # type: ignore - if _ORJSON - else json.dumps(x), - ws_response_class=GatewayWebsocket, - headers=headers, - ) - - self.ws_connect = self.session.ws_connect - - self.global_ratelimit: asyncio.Event = asyncio.Event() - self.global_ratelimit.set() - self.buckets: Dict[str, Bucket] = {} - - async def request( - self, - method, - url, - *args, - attempt: int = 1, - to_discord=True, - guild_id: Union[str, int] = 0, - channel_id: Union[int, str] = 0, - reason: Optional[str] = None, - **kwargs, - ): - if attempt > 5: - logger.critical(f"Failed a {method} {url} 5 times.") - return - - if url.startswith("ws") or not to_discord: - return await self.session.request(method, url, *args, **kwargs) - - if url.startswith("/"): - url = url[1:] - - if url.endswith("/"): - url = url[:-1] - - url = f"{self.base_uri}/{url}" - - headers = self.session.headers - headers.update(kwargs.pop("headers", {})) - - if reason: - headers["X-Audit-Log-Reason"] = reason - - bucket_hash = f"{guild_id}:{channel_id}:{url}" - bucket: Union[Bucket, UnknownBucket] = self.buckets.get( - bucket_hash, UnknownBucket() - ) - - await asyncio.gather(self.global_ratelimit.wait(), bucket.event.wait()) - - res = await self.session.request(method, url, *args, **kwargs) - - await self.log_request(res, kwargs.get("json", kwargs.get("data", None))) - - if isinstance(bucket, UnknownBucket) and res.headers.get("X-RateLimit-Bucket"): - if guild_id or channel_id: - self.buckets[bucket_hash] = Bucket( - discord_hash=res.headers["X-RateLimit-Bucket"] - ) - - else: - b = Bucket(discord_hash=res.headers["X-RateLimit-Bucket"]) - if b in self.buckets.values(): - self.buckets[bucket_hash] = list(self.bucket.values())[ - list(self.bucket.values()).index(b) - ] - else: - self.buckets[bucket_hash] = b - - body: Union[Dict, str] = {} - - if res.headers["Content-Type"] == "application/json": - body = await res.json() - else: - body = await res.text() - - if ( - int(res.headers.get("X-RateLimit-Remaining", 1)) == 0 - and res.status != HTTPCodes.TOO_MANY_REQUESTS - ): - logger.critical( - f"Exhausted {res.headers['X-RateLimit-Bucket']} ({res.url}). " - f"Reset in {res.headers['X-RateLimit-Reset-After']} seconds" - ) - - await asyncio.sleep(float(res.headers["X-RateLimit-Reset-After"])) - if res.status == HTTPCodes.TOO_MANY_REQUESTS: - time_to_sleep: float = ( - body["retry_after"] # type: ignore - if body["retry_after"] > res.headers["X-RateLimit-Reset-After"] # type: ignore - else res.headers["X-RateLimit-Reset-After"] - ) - - logger.critical(f"Rate limited. Reset in {time_to_sleep} seconds") - - if res.headers["X-RateLimit-Scope"] == "global": - self.global_ratelimit.clear() - - await bucket.event.clear() - - await asyncio.sleep(time_to_sleep) - - self.global_ratelimit.set() - bucket.event.set() - - return await self.request(method, url, *args, **kwargs, attempt=attempt + 1) - - if res.status >= HTTPCodes.SERVER_ERROR: - raise DiscordServerError5xx(body) - - elif res.status == HTTPCodes.NOT_FOUND: - raise NotFound404(body) - - elif res.status == HTTPCodes.FORBIDDEN: - raise Forbidden403(body) - - elif not 300 > res.status >= 200: - raise DiscordAPIError(body) - - async def dispose(): - await asyncio.sleep(300) - - with contextlib.suppress(KeyError): - del self.buckets[bucket_hash] - - bucket.close_task.cancel() - - bucket.close_task = asyncio.create_task(dispose()) - - return res - - @staticmethod - async def log_request(res, body: Optional[dict] = None): - message = [ - f"Sent a {res.request_info.method} to {res.url} " - f"and got a {res.status} response. ", - f"Content-Type: {res.headers['Content-Type']} ", - ] - - if body: - message.append(f"Sent body: {body} ") - - if h := dict(res.request_info.headers): - message.append(f"Sent headers: {h} ") - - if h := dict(res.headers): - message.append(f"Received headers: {h} ") - - try: - message.append(f"Received body: {await res.json()} ") - - finally: - logger.debug("".join(message)) - - def base( - self, - method, - url, - *args, - to_discord: bool = True, - **kwargs, - ): - if to_discord: - return self.request(method, url, *args, **kwargs) - return self.session.request(method, url, *args, **kwargs) - - get = partialmethod(base, "GET") - post = partialmethod(base, "POST") - put = partialmethod(base, "PUT") - patch = partialmethod(base, "PATCH") - delete = partialmethod(base, "DELETE") - - async def get_gateway(self) -> discord_typings.GetGatewayData: - """ - Get the gateway url. - Returns: - :class:`GetGatewayData` - """ - res = await self.get("/gateway") - return await res.json() - - async def get_gateway_bot(self) -> discord_typings.GetGatewayBotData: - """ - Get information about the bot logging in, including the gateway url. - This is useful when sharding. - - Returns: - :class:`GetGatewayBotData` - """ - res = await self.get("/gateway/bot") - return await res.json() - - -__all__ = ("HTTPClient",) diff --git a/EpikCord/client/sections.py b/EpikCord/client/sections.py deleted file mode 100644 index e3ff16ff..00000000 --- a/EpikCord/client/sections.py +++ /dev/null @@ -1,28 +0,0 @@ -from collections import defaultdict -from typing import DefaultDict, List, Union - -from ..commands import ClientMessageCommand, ClientSlashCommand, ClientUserCommand -from .websocket_client import Event - - -class Section: - _cmd = Union[ClientUserCommand, ClientSlashCommand, ClientMessageCommand] - - _commands: DefaultDict[str, List[_cmd]] = defaultdict(list) - _events: DefaultDict[str, List[Event]] = defaultdict(list) - - def __init_subclass__(cls, **kwargs): - for attr_value in cls.__dict__.values(): - if isinstance(attr_value, Event): - cls._events[cls.__name__].append(attr_value) - - elif isinstance( - attr_value, - (ClientSlashCommand, ClientUserCommand, ClientMessageCommand), - ): - cls._commands[attr_value.name] = attr_value - - super().__init_subclass__(**kwargs) - - -__all__ = ("Section",) diff --git a/EpikCord/client/user_client.py b/EpikCord/client/user_client.py deleted file mode 100644 index a08942dc..00000000 --- a/EpikCord/client/user_client.py +++ /dev/null @@ -1,110 +0,0 @@ -from __future__ import annotations - -import datetime -from typing import TYPE_CHECKING, List, Optional - -from ..application import Application -from ..guild import Integration -from ..partials import PartialGuild -from ..type_enums import VisibilityType -from ..user import User -from .http_client import HTTPClient - -if TYPE_CHECKING: - import discord_typings - - -class Connection: - def __init__(self, client: UserClient, data: discord_typings.ConnectionData): - self.id: int = int(data["id"]) - self.client = client - self.name: str = data["name"] - self.type: str = data["type"] - self.revoked: Optional[bool] = data["revoked"] - self.integrations: Optional[List[Integration]] = [ - Integration(client, data) for data in data.get("integrations", []) - ] - self.verified: bool = data["verified"] - self.friend_sync: bool = data["friend_sync"] - self.show_activity: bool = data["show_activity"] - self.visibility: VisibilityType = VisibilityType(data["visibility"]) - - -class AuthorizationInformation: - def __init__(self, data: discord_typings.AuthorizationInformationData): - self.application: Application = Application(data["application"]) - self.scopes: List[str] = data["scopes"] - self.expires: datetime.datetime = datetime.datetime.fromisoformat( - data["expires"] - ) - self.user: Optional[User] = ( - User(self, data["user"]) if data.get("user") else None - ) - - def to_dict(self) -> discord_typings.AuthorizationInformationData: - payload: discord_typings.AuthorizationInformationData = { - "application": self.application.to_dict(), - "scopes": self.scopes, - "expires": self.expires.isoformat(), - } - if self.user: - payload["user"] = self.user.to_dict() - - return payload - - -class UserClient: - """ - This class is meant to be used with an Access Token. - Not a User Account Token. This does not support Self Bots. - """ - - def __init__( - self, token: str, *, discord_endpoint: str = "https://discord.com/api/v10" - ): - self.token = token - - self._http: HTTPClient = HTTPClient( - token, - discord_endpoint=discord_endpoint, - ) - self.application: Optional[Application] = None - - async def fetch_application(self) -> Application: - application = Application( - await (await self._http.get("/oauth2/applications/@me")).json() - ) - self.application = application - return application - - async def fetch_authorization_information(self) -> AuthorizationInformation: - data = await (await self._http.get("/oauth2/@me")).json() - if self.application: - data["application"] = self.application.to_dict() - return AuthorizationInformation(data) - - async def fetch_connections(self) -> List[Connection]: - data = await (await self._http.get("/users/@me/connections")).json() - return [Connection(self, d) for d in data] - - async def fetch_guilds( - self, - *, - before: Optional[int] = None, - after: Optional[int] = None, - limit: int = 200, - ) -> List[PartialGuild]: - params = {"limit": limit} - - if before: - params["before"] = before - if after: - params["after"] = after - - data = await self._http.get("/users/@me/guilds", params=params) - guilds = await data.json() - - return [PartialGuild(d) for d in guilds] - - -__all__ = ("UserClient",) diff --git a/EpikCord/client/webhook_client.py b/EpikCord/client/webhook_client.py deleted file mode 100644 index 98b2a5b6..00000000 --- a/EpikCord/client/webhook_client.py +++ /dev/null @@ -1,11 +0,0 @@ -from typing import Optional - -from aiohttp import ClientSession - - -class WebhookClient: - def __init__(self, url: str, session: ClientSession = ClientSession()): - self.url: str = url - self.session = session - - # async def send(self, *, content: Optional[str] = None, username: Optional[str] = None, avatar_url: Optional[str] = None, tts: bool = False, ) diff --git a/EpikCord/client/websocket_client.py b/EpikCord/client/websocket_client.py deleted file mode 100644 index d9071536..00000000 --- a/EpikCord/client/websocket_client.py +++ /dev/null @@ -1,461 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections import defaultdict, deque -from logging import getLogger -from sys import platform -from time import perf_counter -from typing import ( - TYPE_CHECKING, - Any, - Callable, - Coroutine, - DefaultDict, - Deque, - Dict, - List, - Optional, - Union, -) - -from EpikCord.managers import ChannelManager, GuildManager - -from ..close_event_codes import GatewayCECode -from ..close_handler import CloseHandlerLog, CloseHandlerRaise, close_dispatcher -from ..exceptions import ClosedWebSocketConnection -from ..ext.tasks import Task, task -from ..flags import Intents -from ..opcodes import GatewayOpcode -from ..ws_events import setup_ws_event_handler -from .client_application import ClientApplication -from .client_user import ClientUser -from .http_client import HTTPClient - -if TYPE_CHECKING: - import discord_typings - - from EpikCord import Presence - - from .http_client import GatewayWebsocket - -logger = getLogger(__name__) - -Callback = Callable[..., Coroutine[Any, Any, Any]] - - -class Event: - def __init__(self, callback: Callback, *, event_name: str): - self.callback = callback - self.event_name = event_name or callback.__name__ - - -class GatewayRateLimiter: - def __init__(self): - - self.event = asyncio.Event() - self.event.set() - - self.remaining = 120 - self.limit = 120 - self.reset.run() - - @task(seconds=60) - async def reset(self): - self.remaining = self.limit - self.event.set() - - async def tick(self): - await self.event.wait() - self.remaining -= 1 - if self.remaining == 0: - self.event.clear() - - -class WebsocketClient: - def __init__( - self, - token: str, - intents: Union[Intents, int], - presence: Optional[Presence] = None, - discord_endpoint: str = "https://discord.com/api/v10", - ): - from EpikCord import Intents, Utils - - self.token = token - if not token: - raise TypeError("Missing token.") - - if isinstance(intents, int): - self.intents = Intents(intents) - elif isinstance(intents, Intents): - self.intents = intents - - self._closed = True - self.presence = presence - - self.http: HTTPClient = HTTPClient(token, discord_endpoint=discord_endpoint) - - self.events: DefaultDict[str, List[Callback]] = defaultdict(list) - self.wait_for_events: DefaultDict[str, List] = defaultdict(list) - - self.heartbeats: Deque = deque(maxlen=10) - self.heartbeat_interval: Optional[float] = None - self.session_id: Optional[str] = None - self.sequence: Optional[int] = None - self.gateway_url: Optional[str] = None - self.resume_gateway_url: Optional[str] = None - self.websocket: Optional[GatewayWebsocket] = None - - self.utils = Utils(self) - - self.guilds: GuildManager = GuildManager(self) - self.channels: ChannelManager = ChannelManager(self) - - self.user: Optional[ClientUser] = None - self.application: Optional[ClientApplication] = None - - self.websocket_ratelimiter: Optional[GatewayRateLimiter] = None - self.wse_handler = setup_ws_event_handler(self) - self.latencies: Deque = deque(maxlen=10) - - @property - def latency(self) -> Optional[int]: - if not self.latencies: - return None - return sum(self.latencies) / len(self.latencies) - - async def heartbeat(self, forced: bool = False): - if not self.heartbeat_interval: - logger.critical("Cannot heartbeat without an interval.") - return - if not self.websocket: - logger.critical("Cannot heartbeat without a websocket.") - return - - if forced: - await self.send_json( - { - "op": GatewayOpcode.HEARTBEAT, - "d": self.sequence, - } - ) - return - - await asyncio.sleep(self.heartbeat_interval) - await self.send_json( - { - "op": GatewayOpcode.HEARTBEAT, - "d": self.sequence, - } - ) - - start = perf_counter() - async for event in self.websocket: - if event.json()["op"] == GatewayOpcode.HEARTBEAT_ACK: - end = perf_counter() - break - self.latencies.append(end - start) - - async def handle_ws_event(self, event_data): - raw_op_code = event_data["op"] - - try: - op_code = GatewayOpcode(raw_op_code) - except ValueError: - logger.critical("Unknown op code: %s", raw_op_code) - return - - handler = self.wse_handler.get(op_code) - await handler(event_data) - - async def connect(self, reconnect: bool = False): - url = None - - if reconnect: - url = self.resume_gateway_url - else: - if not self.gateway_url: - self.gateway_url = url = (await self.http.get_gateway())["url"] - - logger.info("Connecting to gateway...") - self.websocket = await self.http.ws_connect( # type: ignore - f"{url}?v=10&encoding=json&compress=zlib-stream" - ) - logger.info("Connected to gateway! Listening to events!") - self.websocket_ratelimiter = GatewayRateLimiter() - self._closed = False - - async for event in self.websocket: # type: ignore - event_data = event.json() - logger.debug( - f"Received {event_data} from the Websocket Connection to Discord." - ) - await self.handle_ws_event(event_data) - await self.handle_close() - - async def reconnect(self): - await self.close() - await self.connect() - - async def resume(self): - await self.send_json( - { - "op": GatewayOpcode.RESUME, - "d": { - "token": self.token, - "session_id": self.session_id, - "seq": self.sequence, - }, - } - ) - - async def handle_event(self, event_name: str, data: Dict): - if hasattr(self, f"_{event_name}"): - await getattr(self, f"_{event_name}")(data) - - for wait_for_callback in self.wait_for_events[event_name]: - - try: - check_results = await wait_for_callback[1](data) - except Exception: # TODO: use a more specific exception - return - - if check_results: - wait_for_callback[0].set_result(data) - self.wait_for_events[event_name].remove(wait_for_callback) - - async def dispatch(self, event_name: str, *args: Any, **kwargs: Any): - for callback in self.events[event_name]: - await callback(*args, **kwargs) - logger.info( - f"Dispatched {event_name} to {len(self.events[event_name])} listeners." - ) - - def wait_for( - self, - event_name: str, - *, - check: Optional[Callback] = None, - timeout: float = 0, - ): - """ - Waits for the event to be triggered. - - Parameters - ---------- - event_name : str - The name of the event to wait for. - check : Optional[callable] - A check to run on the event. - If it returns ``False``, the event will be ignored. - timeout : int - The amount of time to wait for the event. - If not specified, it'll wait forever. - """ - future: asyncio.Future = asyncio.Future() - - if not check: - - async def check(*_, **__): - return True - - self.wait_for_events[event_name.lower()].append((future, check)) - return asyncio.wait_for(future, timeout=timeout) - - def event(self, event_name: Optional[str] = None): - def register_event(func): - func_name = event_name or func.__name__.lower() - - if func_name.startswith("on_"): - func_name = func_name[3:] - - self.events[func_name].append(func) - - return Event(func, event_name=func_name) - - return register_event - - async def handle_close(self): - close_code = self.websocket.close_code - - try: - gce_code = GatewayCECode(close_code) - ch_ins = close_dispatcher[gce_code] - - except (ValueError, KeyError) as e: - raise ClosedWebSocketConnection( - f"Connection has been closed with code {self.websocket.close_code}" - ) from e - - if isinstance(ch_ins, CloseHandlerRaise): - raise ch_ins.exception(ch_ins.message) - - if isinstance(ch_ins, CloseHandlerLog): - report_msg = "\n\nReport this immediately" * ch_ins.need_report - logger.critical(ch_ins.message + report_msg) - - if ch_ins.resumable: - await self.resume() - - async def send_json(self, json: dict): - - if not self.websocket: - logger.critical(f"Attempted to send {json} to Discord before connecting.") - return - - if not self.websocket_ratelimiter: - self.websocket_ratelimiter = GatewayRateLimiter() - - await self.websocket_ratelimiter.tick() - await self.websocket.send_json(json) - - logger.debug(f"Sent {json} to the Websocket Connection to Discord.") - - async def close(self) -> None: - if self._closed: - return - - if self.websocket is not None and not self.websocket.closed: - await self.handle_close() - await self.websocket.close(code=4000) - - self._closed = True - - async def identify(self): - await self.send_json( - { - "op": GatewayOpcode.IDENTIFY, - "d": { - "token": self.token, - "intents": self.intents.value, - "properties": { - "os": platform, - "browser": "EpikCord.py", - "device": "EpikCord.py", - }, - }, - } - ) - - heartbeat = Task(self.heartbeat, self.heartbeat_interval, -1) - heartbeat.run() - - def login(self): - loop = asyncio.get_event_loop() - - async def runner(): - try: - await self.connect() - finally: - if not self._closed: - await self.close() - - def stop_loop_on_completion(f: asyncio.Future): - loop.stop() - - future = asyncio.ensure_future(runner(), loop=loop) - future.add_done_callback(stop_loop_on_completion) - - try: - loop.run_forever() - except KeyboardInterrupt: - pass - finally: - future.remove_done_callback(stop_loop_on_completion) - self.utils.cleanup_loop(loop) - - async def _voice_server_update(self, data: discord_typings.VoiceServerUpdateData): - payload = { - "token": data["token"], - "endpoint": data["endpoint"], - "guild_id": data["guild_id"], - } - - if data["endpoint"]: - payload["endpoint"] = data["endpoint"] - - await self.dispatch("voice_server_update", payload) - - async def _voice_state_update(self, data: discord_typings.VoiceStateUpdateData): - from EpikCord import VoiceState - - await self.dispatch( - "voice_state_update", VoiceState(self, data) - ) # TODO: Make this return something like (VoiceState, Member) or make VoiceState get Member from member_id - - async def _guild_delete(self, data: discord_typings.GuildDeleteData): - if guild := self.guilds.remove_from_cache(data["id"]): - await self.dispatch("guild_delete", guild) - - async def _interaction_create(self, data: discord_typings.InteractionCreateData): - interaction = self.utils.interaction_from_type(data) - await self.dispatch("interaction_create", interaction) - - async def _channel_create(self, data: discord_typings.ChannelCreateData): - channel = self.utils.channel_from_type(data) - self.channels.add_to_cache(channel.id, channel) - await self.dispatch("channel_create", channel) - - async def _message_create(self, data: discord_typings.MessageCreateData): - """Event fired when messages are created""" - from EpikCord import Message - - message: Message = Message(self, data) - if not message.channel: - message.channel = await self.channels.fetch(data["channel_id"]) - await self.dispatch("message_create", message) - - async def _guild_create(self, data: discord_typings.GuildCreateData): - from EpikCord import Guild, Thread, UnavailableGuild - - if data.get("unavailable") is None: - return # TODO: Maybe a different event where the name says the Bot is removed on startup. - - guild: Union[UnavailableGuild, Guild] = ( - UnavailableGuild(data) if data.get("unavailable") else Guild(self, data) - ) - - if not guild: - return - - self.guilds.add_to_cache(guild.id, guild) - - for channel in data["channels"]: - self.channels.add_to_cache( - data["id"], self.utils.channel_from_type(channel) - ) - - for thread in data["threads"]: - self.channels.add_to_cache(data["id"], Thread(self, thread)) - - await self.dispatch("guild_create", guild) - # TODO: Add other attributes to cache - - async def _guild_member_update(self, data: discord_typings.GuildMemberUpdateData): - from EpikCord import GuildMember - - guild_member = GuildMember(self, data) # type: ignore - guild = self.guilds.get(data["guild_id"]) - if not guild: - guild = await self.guilds.fetch(int(data["guild_id"])) - if not guild: - logger.critical("Guild was not found in cache, and could not be fetched.") - - guild.members.cache[guild_member.id] = guild_member - await self.dispatch("guild_member_update", guild_member) - - async def _ready(self, data: discord_typings.ReadyData): - from EpikCord import ClientApplication, ClientUser - - self.user = ClientUser(self, data["user"]) - self.session_id = data["session_id"] - self.resume_gateway_url = data["resume_gateway_url"] - application_response = await self.http.get("/oauth2/applications/@me") - application_data = await application_response.json() - - self.application = ClientApplication(self, application_data) - - await self.dispatch("ready") - - -__all__ = ("WebsocketClient", "Event") diff --git a/EpikCord/close_event_codes.py b/EpikCord/close_event_codes.py deleted file mode 100644 index ee89a93e..00000000 --- a/EpikCord/close_event_codes.py +++ /dev/null @@ -1,55 +0,0 @@ -from enum import IntEnum - - -class GatewayCECode(IntEnum): - """Gateway close event code.""" - - AbnormalClosure = 1006 - - UnknownError = 4000 - - UnknownOpcode = 4001 - DecodeError = 4002 - - NotAuthenticated = 4003 - - AuthenticationFailed = 4004 - AlreadyAuthenticated = 4005 - - InvalidSequence = 4007 - RateLimited = 4008 - SessionTimedOut = 4009 - - InvalidShard = 4010 - ShardingRequired = 4011 - - InvalidAPIVersion = 4012 - InvalidIntents = 4013 - DisallowedIntents = 4014 - - -class VoiceCECode(IntEnum): - """Voice close event code.""" - - UnknownOpcode = 4001 - - DecodeError = 4002 - - NotAuthenticated = 4003 - - AuthenticationFailed = 4004 - - AlreadyAuthenticated = 4005 - - SessionExpired = 4006 - SessionTimedOut = 4009 - - ServerNotFound = 4011 - UnknownProtocol = 4012 - - Disconnected = 4013 - VoiceServerCrash = 4014 - UnknownEncryptionMode = 4015 - - -__all__ = ("GatewayCECode", "VoiceCECode") diff --git a/EpikCord/close_handler.py b/EpikCord/close_handler.py deleted file mode 100644 index 3171eb6c..00000000 --- a/EpikCord/close_handler.py +++ /dev/null @@ -1,80 +0,0 @@ -import logging -from typing import Dict, Optional, Type - -from .close_event_codes import GatewayCECode -from .exceptions import ( - DisallowedIntents, - InvalidIntents, - InvalidToken, - Ratelimited429, - ShardingRequired, -) - -logger = logging.getLogger(__name__) - - -class CloseHandler: - def __init__(self, resumable: bool = True): - self.resumable = resumable - - -class CloseHandlerRaise(CloseHandler): - def __init__(self, exception: Type[Exception], message: Optional[str] = None): - super().__init__(resumable=False) - self.exception = exception - self.message = message - - -class CloseHandlerLog(CloseHandler): - def __init__(self, message: str, resumable: bool = True, need_report: bool = False): - super().__init__(resumable=resumable) - self.message = message - self.need_report = need_report - - -ignore = CloseHandler() -close_dispatcher: Dict[GatewayCECode, CloseHandler] = { - GatewayCECode.AbnormalClosure: ignore, - GatewayCECode.UnknownError: ignore, - GatewayCECode.DisallowedIntents: CloseHandlerRaise( - DisallowedIntents, - "You cannot use privileged intents with this token, go to " - "the developer portal and allow the privileged intents " - "needed. ", - ), - GatewayCECode.AuthenticationFailed: CloseHandlerRaise( - InvalidToken, "The token you provided is invalid." - ), - GatewayCECode.RateLimited: CloseHandlerRaise( - Ratelimited429, "You've been rate limited. Try again in a few minutes." - ), - GatewayCECode.ShardingRequired: CloseHandlerRaise( - ShardingRequired, "You need to shard the bot." - ), - GatewayCECode.InvalidAPIVersion: CloseHandlerRaise( - DeprecationWarning, - "The gateway you're connecting to is deprecated and does not " - "work, upgrade EpikCord.py. ", - ), - GatewayCECode.InvalidIntents: CloseHandlerRaise( - InvalidIntents, "The intents you provided are invalid." - ), - GatewayCECode.UnknownOpcode: CloseHandlerLog( - "EpikCord.py sent an invalid OPCODE to the Gateway. ", need_report=True - ), - GatewayCECode.DecodeError: CloseHandlerLog( - "EpikCord.py sent an invalid payload to the Gateway.", need_report=True - ), - GatewayCECode.NotAuthenticated: CloseHandlerLog( - "EpikCord.py has sent a payload prior to identifying.", - resumable=False, - need_report=True, - ), - GatewayCECode.AlreadyAuthenticated: CloseHandlerLog( - "EpikCord.py tried to authenticate again.", need_report=True - ), - GatewayCECode.InvalidSequence: CloseHandlerLog( - "EpikCord.py sent an invalid sequence number.", need_report=True - ), - GatewayCECode.SessionTimedOut: CloseHandlerLog("Session timed out."), -} diff --git a/EpikCord/colour.py b/EpikCord/colour.py deleted file mode 100644 index b58ac43a..00000000 --- a/EpikCord/colour.py +++ /dev/null @@ -1,142 +0,0 @@ -from typing import Any, Tuple, Type, TypeVar - -CT = TypeVar("CT", bound="Colour") - - -class Colour: - # Some of this code is sourced from discord.py, rest assured all the - # colors are different from discord.py - __slots__ = ("value",) - - def __init__(self, value: int): - if not isinstance(value, int): - raise TypeError( - f"Expected int parameter, received {value.__class__.__name__} instead." - ) - - self.value: int = value - - def _get_byte(self, byte: int) -> int: - return (self.value >> (8 * byte)) & 0xFF - - def __eq__(self, other: Any) -> bool: - return isinstance(other, Colour) and self.value == other.value - - def __ne__(self, other: Any) -> bool: - return not self.__eq__(other) - - def __str__(self) -> str: - return f"#{self.value:0>6x}" - - def __int__(self) -> int: - return self.value - - def __repr__(self) -> str: - return f"" - - def __hash__(self) -> int: - return hash(self.value) - - @property - def r(self) -> int: - """Return the red component in rgb""" - return self._get_byte(2) - - @property - def g(self) -> int: - """Return the green component in rgb""" - return self._get_byte(1) - - @property - def b(self) -> int: - """Return the blue component in rgb""" - return self._get_byte(0) - - def to_rgb(self) -> Tuple[int, int, int]: - """Returns a rgb color as a tuple""" - return self.r, self.g, self.b - - @classmethod - def from_rgb(cls: Type[CT], r: int, g: int, b: int) -> CT: - """Constructs a :class:`Colour` from an RGB tuple.""" - return cls((r << 16) + (g << 8) + b) - - @classmethod - def lime_green(cls: Type[CT]) -> CT: - """Returns a color of lime green""" - return cls(0x00FF01) - - @classmethod - def light_green(cls: Type[CT]) -> CT: - """Returns a color of light green""" - return cls(0x00FF22) - - @classmethod - def dark_green(cls: Type[CT]) -> CT: - """Returns a color of dark green""" - return cls(0x00570A) - - @classmethod - def light_blue(cls: Type[CT]) -> CT: - """Returns a color of light blue""" - return cls(0x00FF01) - - @classmethod - def dark_blue(cls: Type[CT]) -> CT: - """Returns a color of dark blue""" - return cls(0x0A134B) - - @classmethod - def light_red(cls: Type[CT]) -> CT: - """Returns a color of light red""" - return cls(0xAA5B54) - - @classmethod - def dark_red(cls: Type[CT]) -> CT: - """Returns a color of dark red""" - return cls(0x4C0000) - - @classmethod - def black(cls: Type[CT]) -> CT: - """Returns a color of black""" - return cls(0x000000) - - @classmethod - def white(cls: Type[CT]) -> CT: - """Returns a color of white""" - return cls(0xFFFFFF) - - @classmethod - def lightmode(cls: Type[CT]) -> CT: - """Returns the color of the background when the color theme in - Discord is set to light mode. An alias of `white`""" - return cls(0xFFFFFF) - - @classmethod - def darkmode(cls: Type[CT]) -> CT: - """Returns the color of the background when the color theme in - Discord is set to dark mode""" - return cls(0x363940) - - @classmethod - def amoled(cls: Type[CT]) -> CT: - """Returns the color of the background when the color theme in - Discord is set to amoled mode. An alias of `black`""" - return cls(0x000000) - - @classmethod - def blurple_old(cls: Type[CT]) -> CT: - """Returns the old Discord Blurple color""" - return cls(0x7289DA) - - @classmethod - def blurple_new(cls: Type[CT]) -> CT: - """Returns the new Discord Blurple color""" - return cls(0x5865F2) - - default = black - - -Color = Colour - -__all__ = ("Colour", "Color") diff --git a/EpikCord/commands.py b/EpikCord/commands.py deleted file mode 100644 index 17e7f735..00000000 --- a/EpikCord/commands.py +++ /dev/null @@ -1,137 +0,0 @@ -from logging import getLogger -from typing import Any, Callable, Coroutine, List, Optional - -from .abstract import BaseCommand -from .exceptions import FailedCheck -from .localizations import Localization -from .options import AnyOption - -logger = getLogger(__name__) - - -Callback = Callable[..., Coroutine[Any, Any, Any]] - - -class Check: - def __init__(self, callback: Callback): - self.callback: Callback = callback - self.success_callback: Callback = self._default_success - self.failure_callback: Callback = self._default_failure - - def success(self, callback: Optional[Callable] = None): - self.success_callback = callback or self._default_success - - def failure(self, callback: Optional[Callable] = None): - self.failure_callback = callback or self._default_failure - - async def default_success(self, interaction): - logger.info( - f"{interaction.author.username} ({interaction.author.id}) passed " - f"the check {self.command_callback.__name__}. " - ) - - async def default_failure(self, interaction): - logger.critical( - f"{interaction.author.username} ({interaction.author.id}) failed " - f"the check {self.command_callback.__name__}. " - ) - raise FailedCheck( - f"{interaction.author.username} ({interaction.author.id}) failed " - f"the check {self.command_callback.__name__}. " - ) - - -class ClientUserCommand(BaseCommand): - """ - A class to represent a User Command that the Client owns. - - Attributes: - ----------- - * name: str - The name set for the User Command - * callback: Callable - The function to call for the User Command - (Passed in by the library) - - Parameters: - ----------- - All parameters follow the documentation of the Attributes accordingly - * name - * callback - * checks - """ - - def __init__(self, *, name: str, callback: Callable, checks: Optional[List[Check]]): - super().__init__(checks or []) - self.name: str = name - self.callback: Callable = callback - - @property - def type(self): - return 2 - - def to_dict(self): - return {"name": self.name} - - -class ClientSlashCommand(BaseCommand): - def __init__( - self, - *, - name: str, - description: str, - callback: Callable, - guild_ids: Optional[List[str]] = None, - options: Optional[List[AnyOption]] = None, - name_localization: Optional[List[Localization]] = None, - description_localization: Optional[List[Localization]] = None, - checks: Optional[List[Check]] = None, - ): - super().__init__(checks or []) - self.name: str = name - self.description: str = description - self.name_localizations: Optional[List[Localization]] = name_localization - self.description_localizations: Optional[ - List[Localization] - ] = description_localization - self.callback: Callable = callback - self.guild_ids: Optional[List[str]] = guild_ids or [] - self.options: Optional[List[AnyOption]] = options or [] - self.autocomplete_options: Dict[str, Callback] = {} - - @property - def type(self): - return 1 - - def option_autocomplete(self, option_name: str): - def wrapper(func): - self.autocomplete_options[option_name] = func - - return wrapper - - def to_dict(self): - payload = { - "name": self.name, - "description": self.description, - "type": self.type, - "options": [option.to_dict() for option in self.options], - } - - if self.name_localizations: - payload["name_localizations"] = [ - loc.to_dict() for loc in self.name_localizations - ] - if self.description_localizations: - payload["description_localizations"] = [ - loc.to_dict() for loc in self.description_localizations - ] - return payload - - -class ClientMessageCommand(ClientUserCommand): - @property - def type(self): - return 3 - - -__all__ = ("ClientUserCommand", "ClientSlashCommand", "ClientMessageCommand", "Check") diff --git a/EpikCord/components.py b/EpikCord/components.py deleted file mode 100644 index 36f7fb1a..00000000 --- a/EpikCord/components.py +++ /dev/null @@ -1,515 +0,0 @@ -from __future__ import annotations - -from enum import IntEnum -from typing import TYPE_CHECKING, List, Optional, Tuple, Union - -from .abstract import BaseComponent -from .exceptions import ( - InvalidArgumentType, - InvalidComponentStyle, - LabelIsTooBig, - TooManyComponents, - TooManySelectMenuOptions, -) -from .partials import PartialEmoji - -if TYPE_CHECKING: - import discord_typings - - -class SelectMenuOption: - def __init__( - self, - label: str, - value: str, - *, - description: Optional[str] = None, - emoji: Optional[PartialEmoji] = None, - default: Optional[bool] = None, - ): - self.label: str = label - self.value: str = value - self.description: Optional[str] = description - self.emoji: Optional[PartialEmoji] = emoji - self.default: Optional[bool] = default - - def to_dict(self): - settings = {"label": self.label, "value": self.value} - - if self.description: - settings["description"] = self.description - - if self.emoji: - if isinstance(self.emoji, PartialEmoji): - settings["emoji"] = self.emoji.to_dict() - - elif isinstance(self.emoji, dict): - settings["emoji"] = self.emoji - - if self.default: - settings["default"] = self.default - - return settings - - -class UserSelectMenu(BaseComponent): - def __init__( - self, - *, - custom_id: str, - placeholder: str, - min_values: Optional[int] = None, - max_values: Optional[int], - disabled: bool = False, - ): - super().__init__(custom_id=custom_id) - self.placeholder: str = placeholder - self.min_values: Optional[int] = min_values - self.disabled: bool = disabled - self.max_values: Optional[int] = max_values - - @property - def type(self) -> int: - return 5 - - def to_dict(self): - from .utils import Utils - - settings = Utils.filter_values( - { - "type": self.type, - "custom_id": self.custom_id, - "disabled": self.disabled, - "placeholder": self.placeholder, - "min_values": self.min_values, - "max_values": self.max_values, - } - ) - - return settings - - -class RoleSelectMenu(BaseComponent): - def __init__( - self, - *, - custom_id: str, - placeholder: str, - min_values: Optional[int] = None, - max_values: Optional[int], - disabled: bool = False, - ): - super().__init__(custom_id=custom_id) - self.placeholder: str = placeholder - self.min_values: Optional[int] = min_values - self.disabled: bool = disabled - self.max_values: Optional[int] = max_values - - @property - def type(self) -> int: - return 6 - - def to_dict(self): - from .utils import Utils - - settings = Utils.filter_values( - { - "type": self.type, - "custom_id": self.custom_id, - "disabled": self.disabled, - "placeholder": self.placeholder, - "min_values": self.min_values, - "max_values": self.max_values, - } - ) - - return settings - - -class MentionableSelectMenu(BaseComponent): - def __init__( - self, - *, - custom_id: str, - placeholder: str, - min_values: Optional[int] = None, - max_values: Optional[int], - disabled: bool = False, - ): - super().__init__(custom_id=custom_id) - self.placeholder: str = placeholder - self.min_values: Optional[int] = min_values - self.disabled: bool = disabled - self.max_values: Optional[int] = max_values - - @property - def type(self) -> int: - return 7 - - def to_dict(self): - from .utils import Utils - - settings = Utils.filter_values( - { - "type": self.type, - "custom_id": self.custom_id, - "disabled": self.disabled, - "placeholder": self.placeholder, - "min_values": self.min_values, - "max_values": self.max_values, - } - ) - - return settings - - -class ChannelSelectMenu(BaseComponent): - def __init__( - self, - *, - custom_id: str, - placeholder: str, - min_values: Optional[int] = None, - max_values: Optional[int], - disabled: bool = False, - channel_types: Optional[List[discord_typings.ChannelTypes]] = None, - ): - super().__init__(custom_id=custom_id) - self.placeholder: str = placeholder - self.min_values: Optional[int] = min_values - self.disabled: bool = disabled - self.max_values: Optional[int] = max_values - self.channel_types: Optional[List[discord_typings.ChannelTypes]] = channel_types - - @property - def type(self) -> int: - return 8 - - def to_dict(self): - from .utils import Utils - - settings = Utils.filter_values( - { - "type": self.type, - "custom_id": self.custom_id, - "disabled": self.disabled, - "placeholder": self.placeholder, - "min_values": self.min_values, - "max_values": self.max_values, - "channel_types": self.channel_types, - } - ) - - return settings - - -class SelectMenu(BaseComponent): - def __init__( - self, - *, - min_values: int = 1, - max_values: int = 1, - disabled: bool = False, - custom_id: str, - ): - super().__init__(custom_id=custom_id) - self.options: List[SelectMenuOption] = [] - self.type: int = 3 - self.min_values = min_values - self.max_values = max_values - self.disabled: bool = disabled - - def to_dict(self): - return { - "type": self.type, - "options": [o.to_dict() for o in self.options], - "min_values": self.min_values, - "max_values": self.max_values, - "disabled": self.disabled, - "custom_id": self.custom_id, - } - - def add_options(self, options: List[SelectMenuOption]): - - for option in options: - - if len(self.options) > 25: - raise TooManySelectMenuOptions( - "You can only have 25 options in a select menu." - ) - - self.options.append(option.to_dict()) - return self - - def set_placeholder(self, placeholder: str): - if not isinstance(placeholder, str): - raise InvalidArgumentType("Placeholder must be a string.") - - self.placeholder = placeholder - return self - - def set_min_values(self, min: int): - if not isinstance(min, int): - raise InvalidArgumentType("Min must be an integer.") - - self.min_values = min - return self - - def set_max_values(self, max: int): - if not isinstance(max, int): - raise InvalidArgumentType("Max must be an integer.") - - self.max_values = max - return self - - def set_disabled(self, disabled: bool): - self.disabled = disabled - - -class TextInput(BaseComponent): - def __init__( - self, - *, - custom_id: str, - style: Union[int, str] = 1, - label: str, - min_length: int = 1, - max_length: int = 4000, - required: bool = True, - value: Optional[str] = None, - placeholder: Optional[str] = None, - ): - super().__init__(custom_id=custom_id) - VALID_STYLES = {"Short": 1, "Paragraph": 2} - - if isinstance(style, str): - if style not in VALID_STYLES: - raise InvalidComponentStyle( - "Style must be either 'Short' or 'Paragraph'." - ) - style = VALID_STYLES[style] - - elif isinstance(style, int): - if style not in VALID_STYLES.values(): - raise InvalidComponentStyle("Style must be either 1 or 2.") - - self.style: int = style - self.type: int = 4 - self.label: str = label - self.min_length: int = min_length - self.max_length: int = max_length - self.required: bool = required - self.value: Optional[str] = value - self.placeholder: Optional[str] = placeholder - - def to_dict(self): - payload = { - "type": 4, - "custom_id": self.custom_id, - "style": self.style, - "label": self.label, - "required": self.required, - } - - if self.min_length: - payload["min_length"] = self.min_length - - if self.max_length: - payload["max_length"] = self.max_length - - if self.value: - payload["value"] = self.value - - if self.placeholder: - payload["placeholder"] = self.placeholder - - return payload - - -class ButtonStyle(IntEnum): - PRIMARY = 1 - SECONDARY = 2 - SUCCESS = GREEN = 3 - DANGER = RED = 4 - LINK = 5 - - -class Button(BaseComponent): - def __init__( - self, - *, - style: Optional[Union[int, str]] = None, - label: Optional[str] = None, - emoji: Optional[Union[PartialEmoji, dict]] = None, - url: Optional[str] = None, - custom_id: str, - disabled: bool = False, - ): - super().__init__(custom_id=custom_id) - self.type: int = 2 - self.disabled = disabled - self.style: Optional[Union[int, str]] = style or 1 - - if url or style == ButtonStyle.LINK: - self.url: Optional[str] = url - self.style = 5 - else: - self.url = None - - self.emoji: Optional[Union[PartialEmoji, dict]] = emoji - self.label: Optional[str] = label - - def set_style(self, style: Union[ButtonStyle, str]): - # check from ButtonStyle IntEnum - if isinstance(style, str): - if not ButtonStyle[style]: - raise InvalidArgumentType("Style must be in ButtonStyle enum.") - - style = ButtonStyle[style] - return self - - elif isinstance(style, ButtonStyle): - style = style.value # type: ignore - return self - - elif style in [1, 2, 3, 4, 5]: - self.style = style - - raise InvalidComponentStyle( - "Invalid button style. Style must be one of the following: " - + ", ".join(btn_style.name for btn_style in ButtonStyle) - ) - - def to_dict(self): - from EpikCord import Utils - - return Utils.filter_values( - { - "type": self.type, - "custom_id": self.custom_id, - "disabled": self.disabled, - "style": self.style, - "label": self.label, - "url": self.url, - "emoji": self.emoji, - } - ) - - def set_label(self, label: str): - if not isinstance(label, str): - raise InvalidArgumentType("Label must be a string.") - - if len(label) > 80: - raise LabelIsTooBig("Label must be 80 characters or less.") - - self.label = label - return self - - def set_emoji(self, emoji: Union[PartialEmoji, dict]): - if isinstance(emoji, dict): - self.emoji = emoji - return self - - elif isinstance(emoji, PartialEmoji): - self.emoji = emoji.data - return self - raise InvalidArgumentType( - "Emoji must be a PartialEmoji or a dict that represents a PartialEmoji." - ) - - def set_url(self, url: str): - - if not isinstance(url, str): - raise InvalidArgumentType("Url must be a string.") - - self.url = url - self.style = 5 - return self - - @classmethod - def from_dict(cls, data): - return Button( - **{ - "custom_id": data["custom_id"], - "style": data.get("style"), - "label": data.get("label"), - "emoji": data.get("emoji"), - "url": data.get("url"), - "disabled": data.get("disabled"), - } - ) - - -def component_from_type(component_data: dict): - component_type = component_data["type"] - del component_data["type"] - if component_type == 2: - return Button(**component_data) - elif component_type == 3: - return SelectMenu(**component_data) - elif component_type == 4: - return TextInput(**component_data) - - -class ActionRow: - def __init__( - self, components: Optional[List[Union[Button, SelectMenu, TextInput]]] = None - ): - self.type: int = 1 - self.components: List[Union[TextInput, Button, SelectMenu]] = components or [] - - def to_dict(self): - return { - "type": self.type, - "components": [component.to_dict() for component in self.components], - } - - @classmethod - def from_dict(cls, data): - return cls( - [component_from_type(component) for component in data.get("components")] - ) - - @staticmethod - def check_still_valid(list_of_components): - buttons = 0 - select_menus = 0 - text_inputs = 0 - - for component in list_of_components: - if isinstance(component, Button): - buttons += 1 - - elif isinstance(component, SelectMenu): - select_menus += 1 - - elif isinstance(component, TextInput): - text_inputs += 1 - - if buttons >= 5 and text_inputs < 1 and select_menus < 1: - raise TooManyComponents( - "You can only have 1 SelectMenu/TextInput per ActionRow" - " or 5 Buttons per ActionRow." - ) - - yield component - - def add_components(self, components: List[Union[Button, SelectMenu]]): - for component in self.check_still_valid(components): - self.components.append(component.to_dict()) - - def add_component(self, component: Union[Button, SelectMenu, TextInput]): - for component in self.check_still_valid(self.components): - ... # Just let the validator run - - self.components.append(component.to_dict()) - - -__all__ = ( - "ActionRow", - "Button", - "ButtonStyle", - "SelectMenu", - "TextInput", - "SelectMenuOption", -) diff --git a/EpikCord/exceptions.py b/EpikCord/exceptions.py deleted file mode 100644 index ea270231..00000000 --- a/EpikCord/exceptions.py +++ /dev/null @@ -1,162 +0,0 @@ -from dataclasses import dataclass - - -class EpikCordException(Exception): - ... - - -class InvalidApplicationCommandType(EpikCordException): - ... - - -class InvalidApplicationCommandOptionType(EpikCordException): - ... - - -class InvalidOption(EpikCordException): - ... - - -class InvalidStatus(EpikCordException): - ... - - -class ClosedWebSocketConnection(EpikCordException): - ... - - -class MissingClientSetting(EpikCordException): - ... - - -class MissingCustomId(EpikCordException): - ... - - -@dataclass -class LocatedError: - code: str - message: str - path: str - - -class DiscordAPIError(EpikCordException): - def __init__(self, body): - self.body = body - self.code = body.get("code") - self.message = body.get("message") - self.errors = body.get("errors") - self.errors_list = self.extract_errors(self.errors) - - super().__init__( - "\n".join(f"{e.path} - {e.code} - {e.message}" for e in self.errors_list) - ) - - def extract_errors(self, d, key_path=None): - """Get _errors key from the given dictionary.""" - if key_path is None: - key_path = [] - - if d.get("_errors"): - return [ - LocatedError(**error, path=".".join(key_path[1:])) - for error in d.get("_errors", []) - ] - - return [ - x - for k, v in d.items() - if isinstance(v, dict) - for x in self.extract_errors(v, key_path + [k]) - ] - - -class InvalidData(EpikCordException): - ... - - -class InvalidIntents(EpikCordException): - ... - - -class ShardingRequired(EpikCordException): - ... - - -class InvalidToken(EpikCordException): - ... - - -class UnhandledEpikCordException(EpikCordException): - ... - - -class DisallowedIntents(EpikCordException): - ... - - -class Unauthorized401(DiscordAPIError): - ... - - -class Forbidden403(DiscordAPIError): - ... - - -class NotFound404(DiscordAPIError): - ... - - -class MethodNotAllowed405(DiscordAPIError): - ... - - -class Ratelimited429(DiscordAPIError): - ... - - -class GateawayUnavailable502(DiscordAPIError): - ... - - -class DiscordServerError5xx(EpikCordException): - ... - - -class TooManyComponents(EpikCordException): - ... - - -class InvalidComponentStyle(EpikCordException): - ... - - -class CustomIdIsTooBig(EpikCordException): - ... - - -class InvalidArgumentType(EpikCordException): - ... - - -class TooManySelectMenuOptions(EpikCordException): - ... - - -class LabelIsTooBig(EpikCordException): - ... - - -class ThreadArchived(EpikCordException): - ... - - -class FailedToConnectToVoice(EpikCordException): - ... - - -class FailedCheck(EpikCordException): - ... - - -# TODO: Add __all__ for this file. diff --git a/EpikCord/ext/__init__.py b/EpikCord/ext/__init__.py deleted file mode 100644 index f5456c32..00000000 --- a/EpikCord/ext/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .tasks import * diff --git a/EpikCord/ext/tasks.py b/EpikCord/ext/tasks.py deleted file mode 100644 index 67f1258b..00000000 --- a/EpikCord/ext/tasks.py +++ /dev/null @@ -1,41 +0,0 @@ -import asyncio -import typing - -# time constants in seconds -MINUTE = 60 -HOUR = MINUTE * 60 -DAY = HOUR * 24 -WEEK = DAY * 7 - - -class Task: - def __init__(self, wrapped_func, duration: int, max_runs: int): - self.wrapped_func = wrapped_func - self.duration = duration - self.max_runs = max_runs - self.runs = 0 - - async def start(self, *args: typing.Any, **kwargs: typing.Any): - while self.runs < self.max_runs: - await self.wrapped_func(*args, **kwargs) - - if self.max_runs > 0: - self.runs += 1 - - await asyncio.sleep(self.duration) - - def run(self, *args: typing.Any, **kwargs: typing.Any): - return asyncio.create_task(self.start(*args, **kwargs)) - - -def task(seconds=0, minutes=0, hours=0, days=0, weeks=0, max_runs=-1): - duration = seconds - duration += minutes * MINUTE - duration += hours * HOUR - duration += days * DAY - duration += weeks * WEEK - - def wrap(function): - return Task(function, duration, max_runs) - - return wrap diff --git a/EpikCord/flags.py b/EpikCord/flags.py deleted file mode 100644 index 74b0ec6a..00000000 --- a/EpikCord/flags.py +++ /dev/null @@ -1,131 +0,0 @@ -from __future__ import annotations - -from typing import Any, Dict, List - - -class Flag: - class_flags: Dict[str, int] - - def __init_subclass__(cls) -> None: - cls.class_flags = {k: v for k, v in cls.__dict__.items() if isinstance(v, int)} - - def __init__(self, value: int = 0, **kwargs): - self.value = value - self.turned_on: List[str] = [k for k, a in kwargs.items() if a] - - for k, v in self.class_flags.items(): - if v & value and k not in self.turned_on: - self.turned_on.append(k) - - self.calculate_from_turned() - - def calculate_from_turned(self): - value = 0 - for key, flag in self.class_flags.items(): - if key in self.class_flags: - value |= flag - self.value = value - - def __getattribute__(self, __name: str) -> Any: - original = super().__getattribute__ - if __name in original("class_flags"): - return __name in original("turned_on") - return original(__name) - - def __setattr__(self, __name: str, __value: Any) -> None: - if __name not in self.class_flags: - return super().__setattr__(__name, __value) - if __value and __name not in self.turned_on: - self.turned_on.append(__name) - elif not __value and __name in self.turned_on: - self.turned_on.remove(__name) - self.calculate_from_turned() - - @classmethod - def all(cls): - return cls(**{k: True for k in cls.class_flags}) - - -class Intents(Flag): - guilds = 1 << 0 - members = 1 << 1 - bans = 1 << 2 - emojis_and_stickers = 1 << 3 - integrations = 1 << 4 - webhooks = 1 << 5 - invites = 1 << 6 - voice_states = 1 << 7 - presences = 1 << 8 - - guild_messages = 1 << 9 - guild_message_reactions = 1 << 10 - guild_message_typing = 1 << 11 - - direct_messages = 1 << 12 - direct_message_reactions = 1 << 13 - direct_message_typing = 1 << 14 - - message_content = 1 << 15 - scheduled_event = 1 << 16 - - auto_moderation_configuration = 1 << 20 - auto_moderation_action_execution = 1 << 21 - - -class SystemChannelFlags(Flag): - - suppress_join_notifications = 1 << 0 - suppress_premium_subscriptions = 1 << 1 - suppress_guild_reminder_notifications = 1 << 2 - suppress_join_notification_replies = 1 << 3 - - -class Permissions(Flag): - create_instant_invite = 1 << 0 - kick_members = 1 << 1 - ban_members = 1 << 2 - administrator = 1 << 3 - manage_channels = 1 << 4 - manage_guild = 1 << 5 - add_reactions = 1 << 6 - view_audit_log = 1 << 7 - priority_speaker = 1 << 8 - stream = 1 << 9 - read_messages = 1 << 10 - send_messages = 1 << 11 - send_tts_messages = 1 << 12 - manage_messages = 1 << 13 - embed_links = 1 << 14 - attach_files = 1 << 15 - read_message_history = 1 << 16 - mention_everyone = 1 << 17 - use_external_emojis = 1 << 18 - connect = 1 << 20 - speak = 1 << 21 - mute_members = 1 << 22 - deafen_members = 1 << 23 - move_members = 1 << 24 - use_voice_activation = 1 << 25 - change_nickname = 1 << 26 - manage_nicknames = 1 << 27 - manage_roles = 1 << 28 - manage_webhooks = 1 << 29 - manage_emojis_and_stickers = 1 << 30 - use_application_commands = 1 << 31 - request_to_speak = 1 << 32 - manage_events = 1 << 33 - manage_threads = 1 << 34 - create_public_threads = 1 << 35 - create_private_threads = 1 << 36 - use_external_stickers = 1 << 37 - send_messages_in_threads = 1 << 38 - start_embedded_activities = 1 << 39 - moderator_members = 1 << 40 - - -class ChannelFlags(Flag): - pinned = 1 << 1 - require_tag = 1 << 4 - - -__all__ = ("Intents", "SystemChannelFlags", "Permissions", "Flag", "ChannelFlags") diff --git a/EpikCord/guild.py b/EpikCord/guild.py deleted file mode 100644 index fc277e4e..00000000 --- a/EpikCord/guild.py +++ /dev/null @@ -1,684 +0,0 @@ -from __future__ import annotations - -import datetime -from typing import TYPE_CHECKING, List, Optional, TypedDict, Union - -from typing_extensions import NotRequired - -from .application import Application, IntegrationApplication -from .channels import AnyChannel, GuildStageChannel, Overwrite -from .flags import Permissions, SystemChannelFlags -from .partials import PartialGuild -from .presence import Activity, Presence, Status -from .sticker import Sticker -from .thread import Thread -from .type_enums import ( - GuildScheduledEventPrivacyLevel, - GuildScheduledEventStatus, - IntegrationExpireBehavior, - Locale, - NSFWLevel, - PremiumTier, - VerificationLevel, -) -from .user import User -from .utils import Utils -from .voice import VoiceState - -if TYPE_CHECKING: - import discord_typings - - -class UnavailableGuild: - """ - The class representation of an UnavailableGuild. - The Guild object should be given to use when the guild is available. - """ - - def __init__(self, data: discord_typings.UnavailableGuildData): - self.data = data - self.id: int = int(data["id"]) - self.unavailable: Optional[bool] = data.get("unavailable") - - -class Invite: - def __init__(self, client, data: discord_typings.InviteData): - self.code: str = data["code"] - self.client = client - self.guild: Optional[PartialGuild] = ( - PartialGuild(data["guild"]) if data.get("guild") else None - ) - self.channel: Optional[AnyChannel] = ( - Utils(client).channel_from_type(data["channel"]) # type: ignore - if data.get("channel") - else None - ) - self.inviter: Optional[User] = ( - User(self.client, data["inviter"]) if data.get("inviter") else None - ) - self.target_type: Optional[int] = data.get("target_type") - self.target_user: Optional[User] = ( - User(self.client, data["target_user"]) if data.get("target_user") else None - ) - self.target_application: Optional[Application] = ( - Application(data["target_application"]) - if data.get("target_application") - else None - ) - self.approximate_presence_count: Optional[int] = data.get( - "approximate_presence_count" - ) - self.approximate_member_count: Optional[int] = data.get( - "approximate_member_count" - ) - self.expires_at: Optional[str] = data.get("expires_at") - self.stage_instance: Optional[GuildStageChannel] = ( - GuildStageChannel(self.client, data["stage_instance"]) - if data.get("stage_instance") - else None - ) - self.guild_scheduled_event: Optional[GuildScheduledEvent] = ( - GuildScheduledEvent(self.client, data["guild_scheduled_event"]) - if data.get("guild_scheduled_event") - else None - ) - - -class GuildMember(User): - def __init__(self, client, data: discord_typings.GuildMemberData): - super().__init__(client, data["user"]) - self.data: discord_typings.GuildMemberData = data # type: ignore - self.client = client - self.nick: Optional[str] = data.get("nick") - self.avatar: Optional[str] = data.get("avatar") - self.role_ids: List[int] = [int(role) for role in data["roles"]] - self.joined_at: datetime.datetime = datetime.datetime.fromisoformat( - data["joined_at"] - ) - self.premium_since: Optional[datetime.datetime] = datetime.datetime.fromisoformat(data["premium_since"]) if data.get("premium_since") else None # type: ignore - self.deaf: bool = data["deaf"] - self.mute: bool = data["mute"] - self.pending: Optional[bool] = data.get("pending") - self.permissions: Optional[Permissions] = ( - Permissions(int(data["permissions"])) if data.get("permissions") else None - ) - self.communication_disabled_until: Optional[datetime.datetime] = ( - datetime.datetime.fromisoformat(data["communication_disabled_until"]) - if data.get("communication_disabled_until") - else None - ) - - -class GuildPreview: - def __init__(self, client, data: discord_typings.GuildPreviewData): - self.id: int = int(data["id"]) - self.client = client - self.name: str = data["name"] - self.icon: Optional[str] = data.get("icon") - self.splash: Optional[str] = data.get("splash") - self.discovery_splash: Optional[str] = data.get("discovery_splash") - self.emojis: List[Emoji] = [Emoji(client, emoji) for emoji in data["emojis"]] - self.features: List[discord_typings.GuildFeaturesData] = data["features"] - self.approximate_member_count: int = data["approximate_member_count"] - self.approximate_presence_count: int = data["approximate_presence_count"] - self.stickers: List[Sticker] = [ - Sticker(self.client, sticker) for sticker in data["stickers"] - ] - - -class Guild: - def __init__(self, client, data: discord_typings.GuildCreateData): - from .flags import SystemChannelFlags - - self.client = client - self.data = data - self.id: int = int(data["id"]) - self.name: str = data["name"] - self.icon: Optional[str] = data.get("icon") - self.icon_hash: Optional[str] = data.get("icon_hash") - self.splash: Optional[str] = data.get("splash") - self.channels: List[AnyChannel] = [] - self.discovery_splash: Optional[str] = data.get("discovery_splash") - self.owner_id: int = int(data["owner_id"]) - self.permissions = Permissions(int(data.get("permissions", 0))) - self.afk_channel_id: Optional[int] = int(data["afk_channel_id"]) if data.get("afk_channel_id") else None # type: ignore - self.afk_timeout: int = data["afk_timeout"] - - self.verification_level: VerificationLevel = VerificationLevel( - data["verification_level"] - ) - - self.default_message_notifications: str = ( - "ALL" if data.get("default_message_notifications") == 0 else "MENTIONS" - ) - self.explicit_content_filter: str = ( - "DISABLED" - if data.get("explicit_content_filter") == 0 - else "MEMBERS_WITHOUT_ROLES" - if data.get("explicit_content_filter") == 1 - else "ALL_MEMBERS" - ) - self.roles: List[Role] = [ - Role(client, {**role_data, "guild": self}) # type: ignore # TODO: Change this to a better method - for role_data in data["roles"] - ] - self.emojis: List[Emoji] = [Emoji(client, emoji) for emoji in data["emojis"]] - self.features: List[discord_typings.GuildFeaturesData] = data["features"] - self.mfa_level: str = "NONE" if data.get("mfa_level") == 0 else "ELEVATED" - self.application_id: Optional[str] = data.get("application_id") - self.system_channel_id: Optional[int] = int(data["system_channel_id"]) if data.get("system_channel_id") else None # type: ignore - self.system_channel_flags: SystemChannelFlags = SystemChannelFlags( - data["system_channel_flags"] - ) - self.rules_channel_id: Optional[int] = ( - int(data["rules_channel_id"]) - if data["rules_channel_id"] is not None - else None - ) - self.max_presences: Optional[int] = data.get("max_presences") - self.max_members: Optional[int] = data.get("max_members") - self.vanity_url_code: Optional[str] = data.get("vanity_url_code") - self.description: Optional[str] = data.get("description") - self.banner: Optional[str] = data.get("banner") - self.premium_tier: PremiumTier = PremiumTier(data["premium_tier"]) - self.premium_subscription_count: Optional[int] = data.get( - "premium_subscription_count" - ) - self.preferred_locale: Locale = Locale(data["preferred_locale"]) - self.public_updates_channel_id: Optional[int] = int(data["public_updates_channel_id"]) if data.get("public_updates_channel_id") else None # type: ignore - self.max_video_channel_users: Optional[int] = data.get( - "max_video_channel_users" - ) - self.approximate_member_count: Optional[int] = data.get( - "approximate_member_count" - ) - self.approximate_presence_count: Optional[int] = data.get( - "approximate_presence_count" - ) - self.welcome_screen: Optional[WelcomeScreen] = ( - WelcomeScreen(data["welcome_screen"]) - if data.get("welcome_screen") - else None - ) - self.nsfw_level: NSFWLevel = NSFWLevel(data["nsfw_level"]) - self.stickers: Optional[List[Sticker]] = ( - [Sticker(self.client, sticker) for sticker in data["stickers"]] - if data.get("stickers") - else None - ) - - # Below are the extra attributes sent over the gateway - - self.joined_at: Optional[datetime.datetime] = ( - datetime.datetime.fromisoformat(data["joined_at"]) # type: ignore - if data.get("joined_at") - else None - ) - self.large: Optional[bool] = data.get("large") - self.unavailable: Optional[bool] = data.get("unavailable") - self.member_count: Optional[int] = data.get("member_count") - self.voice_states: Optional[List[VoiceState]] = ( - [VoiceState(client, voice_state) for voice_state in data["voice_states"]] # type: ignore - if data.get("voice_states") - else None - ) - self.members: Optional[List[GuildMember]] = ( - [GuildMember(client, member) for member in data["members"]] # type: ignore - if data.get("members") - else None - ) - - if data.get("channels"): - self.channels.extend( - [ - client.utils.channel_from_type(channel) - for channel in data["channels"] - ] - ) - - if data.get("threads"): - self.channels.extend( - [Thread(self.client, thread) for thread in data["threads"]] - ) - - self.presences: Optional[List[Presence]] = ( - [ - Presence(activity=p["activities"], status=Status(p["status"])) for p in data["presences"] # type: ignore - ] - if data.get("presences") - else None - ) - self.stage_instances: List[GuildStageChannel] = [ - GuildStageChannel(client, channel) - for channel in data.get("stage_instances", []) - ] - self.guild_scheduled_events: List[GuildScheduledEvent] = [ - GuildScheduledEvent(client, event) - for event in data.get("guild_scheduled_events", []) - ] - - async def edit( - self, - *, - name: Optional[str] = None, - verification_level: Optional[int] = None, - default_message_notifications: Optional[int] = None, - explicit_content_filter: Optional[int] = None, - afk_channel_id: Optional[str] = None, - afk_timeout: Optional[int] = None, - owner_id: Optional[str] = None, - system_channel_id: Optional[str] = None, - system_channel_flags: Optional[SystemChannelFlags] = None, - rules_channel_id: Optional[str] = None, - preferred_locale: Optional[str] = None, - features: Optional[List[str]] = None, - description: Optional[str] = None, - premium_progress_bar_enabled: Optional[bool] = None, - reason: Optional[str] = None, - ) -> Guild: - """Edits the guild. - - Parameters - ---------- - name: Optional[str] - The name of the guild. - verification_level: Optional[int] - The verification level of the guild. - default_message_notifications: Optional[int] - The default message notifications of the guild. - explicit_content_filter: Optional[int] - The explicit content filter of the guild. - afk_channel_id: Optional[str] - The afk channel id of the guild. - afk_timeout: Optional[int] - The afk timeout of the guild. - owner_id: Optional[str] - The owner id of the guild. - system_channel_id: Optional[str] - The system channel id of the guild. - system_channel_flags: Optional[SystemChannelFlags] - The system channel flags of the guild. - rules_channel_id: Optional[str] - The rules channel id of the guild. - preferred_locale: Optional[str] - The preferred locale of the guild. - features: Optional[List[str]] - The features of the guild. - description: Optional[str] - The description of the guild. - premium_progress_bar_enabled: Optional[bool] - Whether the guild has the premium progress bar enabled. - reason: Optional[str] - The reason for editing the guild. - Returns - ------- - :class:`EpikCord.Guild` - """ - data = Utils.filter_values( - { - "name": name, - "verification_level": verification_level, - "default_message_notifications": default_message_notifications, - "explicit_content_filter": explicit_content_filter, - "afk_channel_id": afk_channel_id, - "afk_timeout": afk_timeout, - "owner_id": owner_id, - "system_channel_id": system_channel_id, - "system_channel_flags": ( - system_channel_flags.id if system_channel_flags else None - ), - "rules_channel_id": rules_channel_id, - "preferred_locale": preferred_locale, - "features": features, - "description": description, - "premium_progress_bar_enabled": premium_progress_bar_enabled, - } - ) - - response = await self.client.http.patch( - f"/guilds/{self.id}", json=data, reason=reason, guild_id=self.id - ) - guild_data = await response.json() - - return Guild(self.client, guild_data) - - async def fetch_guild_preview(self) -> GuildPreview: - """Fetches the guild preview. - - Returns - ------- - GuildPreview - The guild preview. - """ - res = await self.client.http.get(f"/guilds/{self.id}/preview", guild_id=self.id) - - data = await res.json() - return GuildPreview(self.client, data) - - async def delete(self): - await self.client.http.delete(f"/guilds/{self.id}", guild_id=self.id) - - async def fetch_channels(self) -> List[AnyChannel]: - """Fetches the guild channels. - - Returns - ------- - List[GuildChannel] - The guild channels. - """ - channels_ = await self.client.http.get( - f"/guilds/{self.id}/channels", guild_id=self.id - ) - return [self.client.utils.channel_from_type(channel) for channel in channels_] - - async def create_channel( - self, - *, - name: str, - reason: Optional[str] = None, - type: Optional[int] = None, - topic: Optional[str] = None, - bitrate: Optional[int] = None, - user_limit: Optional[int] = None, - rate_limit_per_user: Optional[int] = None, - position: Optional[int] = None, - permission_overwrites: Optional[List[Overwrite]] = None, - parent_id: Optional[str] = None, - nsfw: Optional[bool] = None, - ): - """Creates a channel. - - Parameters - ---------- - name: str - The name of the channel. - reason: Optional[str] - The reason for creating the channel. - type: Optional[int] - The type of the channel. - topic: Optional[str] - The topic of the channel. - bitrate: Optional[int] - The bitrate of the channel. - user_limit: Optional[int] - The user limit of the channel. - rate_limit_per_user: Optional[int] - The rate limit per user of the channel. - position: Optional[int] - The position of the channel. - permission_overwrites: Optional[List[Overwrite]] - The permission overwrites of the channel. - parent_id: Optional[str] - The parent id of the channel. - nsfw: Optional[bool] - Whether the channel is nsfw. - """ - data = Utils.filter_values( - { - "name": name, - "type": type, - "topic": topic, - "bitrate": bitrate, - "user_limit": user_limit, - "rate_limit_per_user": rate_limit_per_user, - "position": position, - "permission_overwrites": permission_overwrites, - "parent_id": parent_id, - "nsfw": nsfw, - } - ) - - return self.client.utils.channel_from_type( - await ( - await self.client.http.post( - f"/guilds/{self.id}/channels", - json=data, - reason=reason, - guild_id=self.id, - ) - ).json() - ) - - -class RoleTags: - def __init__(self, data: discord_typings.RoleTagsData): - self.bot_id: Optional[int] = int(data["bot_id"]) if data.get("bot_id") else None - self.integration_id: Optional[int] = ( - int(data["integration_id"]) if data.get("integration_id") else None - ) - self.premium_subscriber: bool = bool(data.get("premium_subscriber")) - - -class Role: - def __init__(self, client, data: discord_typings.RoleData): - self.data = data - self.client = client - self.id: int = int(data["id"]) - self.name: str = data["name"] - self.color: int = data["color"] - self.hoist: bool = data["hoist"] - self.icon: Optional[str] = data.get("icon") - self.unicode_emoji: Optional[str] = data.get("unicode_emoji") - self.position: int = data["position"] - self.permissions: Permissions = Permissions( - int(data["permissions"]) - ) # TODO: Permissions - self.managed: bool = data["managed"] - self.mentionable: bool = data["mentionable"] - self.tags: Optional[RoleTags] = ( - RoleTags(data["tags"]) if data.get("tags") else None - ) - self.guild: Guild = data["guild"] if data.get("guild") else None # type: ignore - - -class EditEmojiData(TypedDict): - name: NotRequired[str] - reason: NotRequired[str] - roles: NotRequired[Optional[List[int]]] - - -class Emoji: - def __init__(self, client, data: discord_typings.EmojiData): - self.client = client - self.id: Optional[int] = int(data["id"]) # type: ignore - self.name: Optional[str] = data.get("name") - self.roles: List[Role] = ( - [ - Role(client, role_data) # type: ignore # TODO: Attach Guild to this or it won't work. - for role_data in data["roles"] - ] - if data.get("roles") - else [] - ) - self.user: Optional[User] = ( - User(self.client, data["user"]) if data.get("user") else None - ) - self.requires_colons: Optional[bool] = data.get("require_colons") - self.guild_id: Optional[str] = data.get("guild_id") # type: ignore # TODO: check if this is input later - self.managed: Optional[bool] = data.get("managed") - self.animated: Optional[bool] = data.get("animated") - self.available: Optional[bool] = data.get("available") - - async def edit( - self, - *, - name: Optional[str] = None, - roles: Optional[List[Role]] = None, - reason: Optional[str] = None, - ): - payload: EditEmojiData = {} - if name: - payload["name"] = name - - if roles: - payload["roles"] = [int(role.id) for role in roles] - - emoji = await self.client.http.patch( - f"/guilds/{self.guild_id}/emojis/{self.id}", json=payload, reason=reason - ) - return Emoji(self.client, emoji) - - async def delete(self, *, reason: Optional[str] = None): - await self.client.http.delete( - f"/guilds/{self.guild_id}/emojis/{self.id}", reason=reason - ) - - -class WelcomeScreenChannel: - def __init__(self, data: discord_typings.WelcomeChannelData): - self.channel_id: int = int(data["channel_id"]) - self.description: str = data["description"] - self.emoji_id: Optional[int] = int(data["emoji_id"]) if data.get("emoji_id") else None # type: ignore - self.emoji_name: Optional[str] = data.get("emoji_name") - - -class WelcomeScreen: - def __init__(self, data: discord_typings.WelcomeScreenData): - self.description: Optional[str] = data.get("description") - self.welcome_channels: List[WelcomeScreenChannel] = [ - WelcomeScreenChannel(welcome_channel) - for welcome_channel in data["welcome_channels"] - ] - - -class GuildWidgetSettings: - def __init__(self, data: dict): - self.enabled: bool = data["enabled"] - self.channel_id: Optional[int] = ( - int(data["channel_id"]) if data.get("channel_id") else None - ) - - -class GuildWidget: - def __init__(self, client, data: discord_typings.GuildWidgetData): - self.client = client - self.data = data - self.id: int = int(data["id"]) - self.name: str = data["name"] - self.instant_invite: Optional[str] = data["instant_invite"] - self.channels: List[AnyChannel] = [ - self.client.utils.channel_from_type(channel) - for channel in data.get("channels", []) - ] - self.users: List[User] = [User(client, user) for user in data["members"]] - self.presence_count: int = data["presence_count"] - - -class GuildScheduledEventEntityMetadata: - def __init__(self, data: discord_typings.GuildScheduledEventEntityMetadataData): - self.location: Optional[str] = data.get("location") - - -class GuildScheduledEvent: - def __init__(self, client, data: discord_typings.GuildScheduledEventData): - self.client = client - self.id: int = int(data["id"]) - self.guild_id: int = int(data["guild_id"]) - self.channel_id: Optional[int] = int(data["channel_id"]) if data.get("channel_id") else None # type: ignore - self.creator_id: Optional[int] = ( - int(data["creator_id"]) if data.get("creator_id") else None - ) - self.name: str = data["name"] - self.description: Optional[str] = data.get("description") - self.scheduled_start_time: datetime.datetime = datetime.datetime.fromisoformat( - data["scheduled_start_time"] - ) - self.scheduled_end_time: Optional[datetime.datetime] = ( - datetime.datetime.fromisoformat(data["scheduled_end_time"]) - if data.get("scheduled_end_time") - else None - ) - self.privacy_level: GuildScheduledEventPrivacyLevel = ( - GuildScheduledEventPrivacyLevel(data["privacy_level"]) - ) - self.status = GuildScheduledEventStatus(data["status"]) - - self.entity_type: str = ( - "STAGE_INSTANCE" - if data.get("entity_type") == 1 - else "VOICE" - if data.get("entity_type") == 2 - else "EXTERNAL" - ) - self.entity_id: Optional[int] = int(data["entity_id"]) if data.get("entity_id") else None # type: ignore - self.entity_metadata: Optional[GuildScheduledEventEntityMetadata] = GuildScheduledEventEntityMetadata(data["entity_metadata"]) if data.get("entity_metadata") else None # type: ignore - self.creator: Optional[User] = ( - User(client, data["creator"]) if data.get("creator") else None - ) - self.user_count: Optional[int] = data.get("user_count") - - -class IntegrationAccount: - def __init__(self, data: discord_typings.IntegrationAccountData): - self.id: str = data["id"] - self.name: str = data["name"] - - -class GuildBan: - def __init__(self, client, data: discord_typings.BanData): - self.reason: Optional[str] = data.get("reason") - self.client = client - self.user: User = User(client, data["user"]) - - -class Integration: - def __init__( - self, - client, - data: Union[ - discord_typings.StreamingIntegrationData, - discord_typings.DiscordIntegrationData, - ], - ): - self.id: str = str(data["id"]) - self.client = client - self.name: str = data["name"] - self.type: str = data["type"] - - self.enabled: Optional[bool] = data.get("enabled") # type: ignore - self.syncing: Optional[bool] = data.get("syncing") # type: ignore - self.role_id: Optional[int] = int(data["role_id"]) if data.get("role_id") else None # type: ignore - self.subscriber_count: Optional[int] = data.get("subscriber_count") # type: ignore - self.revoked: Optional[bool] = data.get("revoked") # type: ignore - self.enable_emoticons: Optional[bool] = data.get("enable_emoticons") # type: ignore - self.expire_grace_period: Optional[int] = data.get("expire_grace_period") # type: ignore - - self.expire_behavior: Optional[IntegrationExpireBehavior] = ( - IntegrationExpireBehavior(data["expire_behavior"]) # type: ignore - if data.get("expire_behavior") - else None - ) - self.user: Optional[User] = ( - User(client, data["user"]) if data.get("user") else None # type: ignore - ) - self.account: IntegrationAccount = IntegrationAccount(data["account"]) - self.synced_at: Optional[datetime.datetime] = ( - datetime.datetime.fromisoformat(data["synced_at"]) # type: ignore - if data.get("synced_at") - else None - ) - self.application: Optional[IntegrationApplication] = ( - IntegrationApplication(client, data["application"]) - if data.get("application") - else None - ) - - -__all__ = ( - "Guild", - "UnavailableGuild", - "Invite", - "GuildMember", - "GuildPreview", - "Role", - "RoleTags", - "Emoji", - "WelcomeScreenChannel", - "WelcomeScreen", - "GuildWidgetSettings", - "GuildWidget", - "GuildScheduledEvent", - "IntegrationAccount", - "Integration", - "GuildBan", -) diff --git a/EpikCord/interactions.py b/EpikCord/interactions.py deleted file mode 100644 index 339ca094..00000000 --- a/EpikCord/interactions.py +++ /dev/null @@ -1,252 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, List, Literal, Optional, TypedDict, Union - -from typing_extensions import NotRequired - -from .abstract import BaseInteraction -from .components import * -from .options import * - -if TYPE_CHECKING: - import discord_typings - - from .message import Attachment, Embed, MessagePayload - - -class Modal: - def __init__(self, *, title: str, custom_id: str, components: List[ActionRow]): - self.title = title - self.custom_id = custom_id - self.components = [component.to_dict() for component in components] - - def to_dict(self): - return { - "title": self.title, - "custom_id": self.custom_id, - "components": [component.to_dict() for component in self.components], - } - - -class ResolvedDataHandler: - def __init__( - self, client, resolved_data: discord_typings.ResolvedInteractionDataData - ): - self.data: discord_typings.ResolvedInteractionDataData = resolved_data - - -class DeferredInteractionResponse(TypedDict): - type: Literal[5, 6] - data: NotRequired[Optional[MessagePayload]] - - -class BaseComponentInteraction(BaseInteraction): - def __init__(self, client, data: discord_typings.ComponentInteractionData): - super().__init__(client, data) - from EpikCord import Message - - self.message: Message = Message(client, data["message"]) - self.custom_id: str = data["data"]["custom_id"] - self.component_type: int = data["data"]["component_type"] - - async def defer(self, *, ephemeral: bool = False, show_loading_state: bool = True): - data: DeferredInteractionResponse = {"type": 5} - - if ephemeral: - data["data"] = {"flags": 1 << 6} - - if not show_loading_state: - data["type"] = 6 - - await self.client.http.post( - f"/interaction/{self.id}/{self.token}/callback", json=data - ) - - def is_action_row(self): - return self.component_type == 1 - - def is_button(self): - return self.component_type == 2 - - def is_select_menu(self): - return self.component_type == 3 - - def is_text_input(self): - return self.component_type == 4 - - async def update( - self, - *, - tts: bool = False, - content: Optional[str] = None, - embeds: Optional[List[Embed]] = None, - allowed_mentions=None, - components: Optional[List[Union[Button, SelectMenu, TextInput]]] = None, - attachments: Optional[List[Attachment]] = None, - suppress_embeds: Optional[bool] = False, - ) -> None: - - message_data: MessagePayload = {"tts": tts, "flags": 0} - - if suppress_embeds: - message_data["flags"] += 1 << 2 - - if content: - message_data["content"] = content - if embeds: - message_data["embeds"] = [embed.to_dict() for embed in embeds] - if allowed_mentions: - message_data["allowed_mentions"] = allowed_mentions.to_dict() - if components: - message_data["components"] = [ - component.to_dict() for component in components - ] - if attachments: - message_data["attachments"] = [ - attachment.to_dict() for attachment in attachments - ] - - payload = {"type": 7, "data": message_data} - - await self.client.http.patch( - f"/interaction/{self.id}/{self.token}/callback", json=payload - ) - - async def defer_update(self): - await self.client.http.post( - f"/interaction/{self.id}/{self.token}/callback", json={"type": 6} - ) - - -class ButtonInteraction(BaseComponentInteraction): - ... - - -class SelectMenuInteraction(BaseComponentInteraction): - def __init__(self, client, data): - super().__init__(client, data) - self.values: List[discord_typings.SelectMenuOptionData] = data["data"]["values"] - - -class ModalSubmitInteraction(BaseInteraction): - def __init__(self, client, data: dict): - super().__init__(client, data) - self.custom_id: str = data["data"]["custom_id"] - self._components: List[Union[Button, SelectMenu, TextInput]] = data["data"][ - "components" - ] - - async def send_modal(self, *_, **__): - raise NotImplementedError("ModalSubmitInteractions cannot send modals.") - - -class AutoCompleteInteractionData(TypedDict): - choices: List[ - Union[ - discord_typings.interactions.commands.StrCommandOptionChoiceData, - discord_typings.interactions.commands.IntCommandOptionChoiceData, - ] - ] - - -class AutoCompleteInteractionResponse(TypedDict): - type: Literal[9] - data: AutoCompleteInteractionData - - -class AutoCompleteInteraction(BaseInteraction): - def __init__(self, client, data: dict): - super().__init__(client, data) - conversion_type = { - 1: Subcommand, - 2: SubCommandGroup, - 3: StringOption, - 4: IntegerOption, - 5: BooleanOption, - 6: UserOption, - 7: ChannelOption, - 8: RoleOption, - 9: MentionableOption, - 10: NumberOption, - 11: AttachmentOption, - } - self.options: List[AnyOption] = [ - conversion_type[option["type"]](**option) - for option in data.get("options", []) - ] - - async def reply(self, choices: List[SlashCommandOptionChoice]) -> None: # type: ignore - payload: AutoCompleteInteractionResponse = {"type": 9, "data": {"choices": []}} - - for choice in choices: - if not isinstance(choice, SlashCommandOptionChoice): - raise TypeError(f"{choice} must be of type SlashCommandOptionChoice") - - payload["data"]["choices"].append(choice.to_dict()) - - await self.client.http.post( - f"/interactions/{self.id}/{self.token}/callback", json=payload - ) - - -class ApplicationCommandInteraction(BaseInteraction): - def __init__(self, client, data: discord_typings.ApplicationCommandInteractionData): - super().__init__(client, data) - self.command_id: int = int(data["data"]["id"]) - self.command_name: str = data["data"]["name"] - self.command_type: int = data["data"]["type"] - self.resolved = ( - ResolvedDataHandler(client, data["data"]["resolved"]) - if data.get("resolved") - else None - ) - self.options: Optional[List[ReceivedOption]] = ( - [ReceivedOption(option) for option in data["data"]["options"]] - if data["data"].get("options") - else None - ) - - -class UserCommandInteraction(ApplicationCommandInteraction): - def __init__(self, client, data): - super().__init__(client, data) - self.target_id: str = data.get("target_id") - - -class MessageCommandInteraction(UserCommandInteraction): - ... # Literally the same thing. - - -class MessageInteraction: - def __init__(self, client, data: discord_typings.MessageInteractionData): - from EpikCord import GuildMember, User - - self.id: int = int(data["id"]) - self.type: int = data["type"] - self.name: str = data["name"] - self.user: User = User(client, data["user"]) - self.member: Optional[GuildMember] = GuildMember(client, {**data["member"], **data["user"]}) if data.get("member") else None # type: ignore - - -AnyInteraction = Union[ - ButtonInteraction, - SelectMenuInteraction, - ModalSubmitInteraction, - AutoCompleteInteraction, - ApplicationCommandInteraction, - UserCommandInteraction, - MessageCommandInteraction, -] - -__all__ = ( - "Modal", - "ModalSubmitInteraction", - "ResolvedDataHandler", - "ButtonInteraction", - "SelectMenuInteraction", - "AutoCompleteInteraction", - "ApplicationCommandInteraction", - "UserCommandInteraction", - "MessageCommandInteraction", - "MessageInteraction", -) diff --git a/EpikCord/localizations.py b/EpikCord/localizations.py deleted file mode 100644 index 8c343443..00000000 --- a/EpikCord/localizations.py +++ /dev/null @@ -1,15 +0,0 @@ -from .type_enums import Locale - - -class Localization: - def __init__(self, locale: Locale, value: str): - self.locale: str = str(locale) - self.value: str = value - - def to_dict(self): - return {self.locale: self.value} - - -Localisation = Localization - -__all__ = ("Localization", "Localisation") diff --git a/EpikCord/managers/__init__.py b/EpikCord/managers/__init__.py deleted file mode 100644 index d27ab5d8..00000000 --- a/EpikCord/managers/__init__.py +++ /dev/null @@ -1,24 +0,0 @@ -""" -Copyright © 2021-present EpikHost -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 .cache_manager import * -from .channel_manager import * -from .guilds_manager import * -from .roles_manager import * diff --git a/EpikCord/managers/cache_manager.py b/EpikCord/managers/cache_manager.py deleted file mode 100644 index 5cd25a0f..00000000 --- a/EpikCord/managers/cache_manager.py +++ /dev/null @@ -1,80 +0,0 @@ -from __future__ import annotations - -from typing import Any, Dict, Iterator, Optional, Union - - -class CacheManager: - def __init__(self): - self.cache: Dict[Any, Any] = {} - - def add_to_cache(self, key: Union[int, str], value: Any): - self.cache[key] = value - - def remove_from_cache(self, key): - self.cache.pop(key, None) - - def get(self, key, default: Optional[Any] = None) -> Any: - return self.cache.get(key, default) - - def is_in_cache(self, key: str): - return key in self.cache - - def clear_cache(self): - self.cache = {} - - def __dict__(self) -> Dict: # type: ignore - return self.cache - - def __str__(self) -> str: - return f"" - - def __int__(self) -> int: - return len(self.cache) - - def __len__(self) -> int: - return len(self.cache) - - def __repr__(self) -> str: - return self.__str__() - - def __getitem__(self, key: str) -> Any: - return self.cache[key] - - def __setitem__(self, key: str, value: Any) -> Any: - self.cache[key] = value - - def __delitem__(self, key: str) -> None: - self.cache.pop(key, None) - - def __contains__(self, key: str) -> bool: - return key in self.cache - - def __iter__(self) -> Iterator: - return iter(self.cache) - - def __eq__(self, other: CacheManager) -> bool: # type: ignore - return self.cache == other.cache - - def __ne__(self, other: CacheManager) -> bool: # type: ignore - return self.cache != other.cache - - def __gt__(self, other: CacheManager) -> bool: - return len(self.cache) > len(other.cache) - - def __ge__(self, other: CacheManager) -> bool: - return len(self.cache) >= len(other.cache) - - def __lt__(self, other: CacheManager) -> bool: - return len(self.cache) < len(other.cache) - - def __le__(self, other: CacheManager) -> bool: - return len(self.cache) <= len(other.cache) - - def __hash__(self) -> int: - return hash(self.cache) - - def __call__(self) -> Dict: - return self.cache - - -# ! This is the base cache manager, people can extend this to make their own cache managers diff --git a/EpikCord/managers/channel_manager.py b/EpikCord/managers/channel_manager.py deleted file mode 100644 index efc91316..00000000 --- a/EpikCord/managers/channel_manager.py +++ /dev/null @@ -1,23 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Optional, Union - -from .cache_manager import CacheManager - -if TYPE_CHECKING: - from ..channels import AnyChannel - from ..client.client import Client, WebsocketClient - - -class ChannelManager(CacheManager): - def __init__(self, client: Union[Client, WebsocketClient]): - super().__init__() - self.client = client - - async def fetch(self, channel_id: int) -> Optional[AnyChannel]: - channel = await self.client.http.get( - f"channels/{channel_id}", channel_id=channel_id - ) - if data := await channel.json(): - return self.client.utils.channel_from_type(data) - return None diff --git a/EpikCord/managers/guilds_manager.py b/EpikCord/managers/guilds_manager.py deleted file mode 100644 index ba13b038..00000000 --- a/EpikCord/managers/guilds_manager.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Optional, Union - -from .cache_manager import CacheManager - -if TYPE_CHECKING: - from ..client.client import Client, WebsocketClient - - -class GuildManager(CacheManager): - def __init__(self, client: Union[Client, WebsocketClient]): - super().__init__() - self.client = client - - async def fetch( - self, - guild_id: int, - *, - with_counts: Optional[bool] = False, - ): - from EpikCord import Guild - - if with_counts: - return Guild( - self.client, - await self.client.http.get(f"/guilds/{guild_id}?with_counts=true"), - ) - - return Guild(self.client, await self.client.http.get(f"/guilds/{guild_id}")) diff --git a/EpikCord/managers/member_manager.py b/EpikCord/managers/member_manager.py deleted file mode 100644 index ab2850fb..00000000 --- a/EpikCord/managers/member_manager.py +++ /dev/null @@ -1,15 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Union - -if TYPE_CHECKING: - from ..client.client import Client, WebsocketClient - -from .cache_manager import CacheManager - - -class MemberManager(CacheManager): - def __init__(self, client: Union[Client, WebsocketClient], guild_id: int): - super().__init__() - self.client = client - self.guild_id: int = guild_id diff --git a/EpikCord/managers/roles_manager.py b/EpikCord/managers/roles_manager.py deleted file mode 100644 index e69de29b..00000000 diff --git a/EpikCord/mentioned.py b/EpikCord/mentioned.py deleted file mode 100644 index f2aea2e8..00000000 --- a/EpikCord/mentioned.py +++ /dev/null @@ -1,29 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Optional - -from .user import User - -if TYPE_CHECKING: - import discord_typings - - -class MentionedUser(User): - def __init__(self, client, data: discord_typings.UserMentionData): - from EpikCord import GuildMember - - super().__init__(client, data) - self.member: Optional[GuildMember] = ( - GuildMember(client, data["member"]) if data.get("member") else None - ) - - -class MentionedChannel: - def __init__(self, data: discord_typings.ChannelMentionData): - self.id: int = int(data["id"]) - self.guild_id: int = int(data["guild_id"]) - self.type: int = data["type"] - self.name: str = data["name"] - - -__all__ = ("MentionedUser", "MentionedChannel") diff --git a/EpikCord/message.py b/EpikCord/message.py deleted file mode 100644 index 6564887a..00000000 --- a/EpikCord/message.py +++ /dev/null @@ -1,577 +0,0 @@ -import asyncio -import datetime -import io -import os -from logging import getLogger -from typing import Any, Dict, List, Optional, TypedDict, Union -from urllib.parse import quote as _quote - -import discord_typings -from typing_extensions import NotRequired - -from .application import Application -from .colour import Colour -from .components import * -from .mentioned import MentionedChannel -from .partials import PartialEmoji -from .sticker import * -from .thread import Thread -from .type_enums import AllowedMentionTypes -from .user import User -from .utils import Utils -from .webhooks import WebhookUser - -logger = getLogger(__name__) - - -def _filter_values(dictionary: dict) -> dict: - return {k: v for k, v in dictionary.items() if v is not None} - - -class AllowedMention: - def __init__( - self, - allowed_mentions: Union[List[AllowedMentionTypes], AllowedMentionTypes] = [], - replied_user: bool = True, - roles: List[int] = [], - users: List[int] = [], - ): - self.allowed_mentions: Union[ - List[AllowedMentionTypes], AllowedMentionTypes - ] = allowed_mentions - self.replied_user: bool = replied_user - self.roles: List[int] = roles - self.users: List[int] = users - - def to_dict(self) -> Dict[str, Any]: - return { - "parse": self.allowed_mentions, - "roles": self.roles, - "users": self.users, - "replied_user": self.replied_user, - } - - -class MessageActivity: - def __init__(self, data: discord_typings.MessageActivityData): - self.type: int = data["type"] - self.party_id: Optional[str] = data.get("party_id") - - -class Attachment: - def __init__(self, data: discord_typings.AttachmentData): - self.id: int = int(data["id"]) - self.file_name: str = data["filename"] - self.description: Optional[str] = data.get("description") - self.content_type: Optional[str] = data.get("content_type") - self.size: int = data["size"] - self.url: str = data["url"] - self.proxy_url: str = data["proxy_url"] - self.width: Optional[int] = data.get("width") - self.height: Optional[int] = data.get("height") - self.ephemeral: Optional[bool] = data.get("ephemeral") - - def to_dict(self) -> discord_typings.AttachmentData: - return _filter_values( # type: ignore - { - "id": self.id, - "filename": self.file_name, - "description": self.description, - "content_type": self.content_type, - "size": self.size, - "url": self.url, - "proxy_url": self.proxy_url, - "width": self.width, - "height": self.height, - "ephemeral": self.ephemeral, - } - ) - - -class Reaction: - """ - A class representation of a Reaction. - Not for direct use. - - - Attributes - ---------- - count: int - The amount of times this reaction has been added to the Message. - me: bool - If the ClientUser has reacted to this Message with this Reaction. - emoji: PartialEmoji - The partial emoji of this Reaction. - """ - - def __init__(self, data: discord_typings.MessageReactionData): - self.count: int = data["count"] - self.me: bool = data.get["me"] # type: ignore - self.emoji: PartialEmoji = PartialEmoji(data["emoji"]) - - -class Embed: - def __init__( - self, - *, - title: Optional[str] = None, - description: Optional[str] = None, - color: Optional[Union[Colour, int]] = None, - colour: Optional[Union[Colour, int]] = None, - video: Optional[discord_typings.EmbedVideoData] = None, - timestamp: Optional[datetime.datetime] = None, - url: Optional[str] = None, - footer: Optional[discord_typings.EmbedFooterData] = None, - image: Optional[discord_typings.EmbedImageData] = None, - thumbnail: Optional[discord_typings.EmbedThumbnailData] = None, - provider: Optional[discord_typings.EmbedProviderData] = None, - author: Optional[discord_typings.EmbedAuthorData] = None, - fields: List[discord_typings.EmbedFieldData] = [], - ): - self.title: Optional[str] = title - self.description: Optional[str] = description - self.url: Optional[str] = url - self.video: Optional[discord_typings.EmbedVideoData] = video - self.timestamp: Optional[datetime.datetime] = timestamp or None - self.color: Optional[Colour] = color or colour # type: ignore - self.footer: Optional[discord_typings.EmbedFooterData] = footer - self.image: Optional[discord_typings.EmbedImageData] = image - self.thumbnail: Optional[discord_typings.EmbedThumbnailData] = thumbnail - self.provider: Optional[discord_typings.EmbedProviderData] = provider - self.author: Optional[discord_typings.EmbedAuthorData] = author - self.fields: List[discord_typings.EmbedFieldData] = fields - - def add_field(self, *, name: str, value: str, inline: bool = False): - self.fields.append({"name": name, "value": value, "inline": inline}) - - def set_thumbnail( - self, - *, - url: str, - proxy_url: Optional[str] = None, - height: Optional[int] = None, - width: Optional[int] = None, - ): - config: discord_typings.EmbedThumbnailData = {"url": url} - if proxy_url: - config["proxy_url"] = proxy_url - if height: - config["height"] = height - if width: - config["width"] = width - - self.thumbnail = config - - def set_video( - self, - *, - url: str, - proxy_url: Optional[str] = None, - height: Optional[int] = None, - width: Optional[int] = None, - ): - config: discord_typings.EmbedVideoData = {"url": url} - if proxy_url: - config["proxy_url"] = proxy_url - if height: - config["height"] = height - if width: - config["width"] = width - - self.video = config - - def set_image( - self, - *, - url: str, - proxy_url: Optional[str] = None, - height: Optional[int] = None, - width: Optional[int] = None, - ): - config: discord_typings.EmbedImageData = {"url": url} - if proxy_url: - config["proxy_url"] = proxy_url - if height: - config["height"] = height - if width: - config["width"] = width - - self.image = config - - def set_provider(self, *, name: Optional[str] = None, url: Optional[str] = None): - config: discord_typings.EmbedProviderData = {} - if name: - config["name"] = name - if url: - config["url"] = url - self.provider = config - - def set_footer( - self, - *, - text: str, - icon_url: Optional[str] = None, - proxy_icon_url: Optional[str] = None, - ): - payload: discord_typings.EmbedFooterData = { - "text": text, - } - if icon_url: - payload["icon_url"] = icon_url - if proxy_icon_url: - payload["proxy_icon_url"] = proxy_icon_url - self.footer = payload - - def set_author( - self, - name: str, - url: Optional[str] = None, - icon_url: Optional[str] = None, - proxy_icon_url: Optional[str] = None, - ): - payload: discord_typings.EmbedAuthorData = {"name": name} - if url: - payload["url"] = url - if icon_url: - payload["icon_url"] = icon_url - if proxy_icon_url: - payload["proxy_icon_url"] = proxy_icon_url - - self.author = payload - - def set_fields(self, *, fields: List[discord_typings.EmbedFieldData]): - for field in fields: - - if not field["name"] or not field["value"]: - raise ValueError("Field name and value are required") - - self.fields = fields - - def set_color(self, *, colour: Colour): - self.color = colour - - def set_timestamp(self, *, timestamp: Optional[datetime.datetime] = None): - self.timestamp = timestamp or None - - def set_title(self, title: Optional[str] = None): - self.title = title - - def set_description(self, description: Optional[str] = None): - self.description = description - - def set_url(self, url: Optional[str] = None): - self.url = url - - def to_dict(self): - return { - key: value - for key, value in self.__dict__.items() - if value is not None or key.startswith("_") - } - - @classmethod - def from_dict(cls, data: discord_typings.EmbedData): - payload = data - payload.pop("type", None) - if data.get("timestamp"): - payload["timestamp"] = datetime.datetime.fromisoformat(data["timestamp"]) # type: ignore - return cls(**payload) # type: ignore - - -class File: - """ - Represents a file. Sourced from Discord.py - """ - - def __init__( - self, - fp: Union[str, bytes, os.PathLike, io.BufferedIOBase], - filename: Optional[str] = None, - *, - spoiler: bool = False, - ): - if isinstance(fp, io.IOBase): - if not (fp.seekable() and fp.readable()): - raise ValueError(f"File buffer {fp!r} must be seekable and readable") - self.fp = fp - self._original_pos = fp.tell() - else: - self.fp = open(fp, "rb") - self._original_pos = 0 - self._closer = self.fp.close - self.fp.close = lambda: None # type: ignore - - if filename is None: - if isinstance(fp, str): - _, self.filename = os.path.split(fp) - else: - self.filename = getattr(fp, "name", None) # type: ignore - else: - self.filename = filename - if ( - spoiler - and self.filename is not None - and not self.filename.startswith("SPOILER_") - ): - self.filename = f"SPOILER_{self.filename}" - - self.spoiler = spoiler or ( - self.filename is not None and self.filename.startswith("SPOILER_") - ) - - def reset(self, *, seek: Union[int, bool] = True) -> None: - if seek: - self.fp.seek(self._original_pos) - - def close(self) -> None: - self.fp.close = self._closer # type: ignore - self._closer() - - -class MessageReference: - def __init__(self, data: discord_typings.MessageReferenceData) -> None: - self.message_id: Optional[int] = ( - int(data["message_id"]) if data.get("message_id") else None - ) - self.channel_id: Optional[int] = ( - int(data["channel_id"]) if data.get("channel_id") else None - ) - self.guild_id: Optional[int] = ( - int(data["guild_id"]) if data.get("guild_id") else None - ) - self.fail_if_not_exists: Optional[bool] = data.get("fail_if_not_exists") - - -class Message: - """Represents a Discord message. - - Attributes - ---------- - client : Client - The client which initialised this Message. - id : str - The message ID. - channel_id : str - The channel ID the message was sent in. - author : Union[GuildMember, User] - The author of the message - guild_id: str - The Guild ID the message was sent in - - """ - - def __init__(self, client, data: discord_typings.MessageData): - from EpikCord import GuildMember, Reaction - - self.client = client - self.id: int = int(data["id"]) - self.channel_id: int = int(data["channel_id"]) - self.guild_id: Optional[int] = int(data["guild_id"]) if data.get("guild_id") else None # type: ignore - self.webhook_id: Optional[int] = ( - int(data["webhook_id"]) if data.get("webhook_id") else None - ) - self.author: Optional[Union[WebhookUser, GuildMember, User]] = None - - if self.webhook_id: - self.author = WebhookUser(data["author"]) - - if data.get("member"): - member_data = data["member"] # type: ignore - if data.get("author"): - member_data["user"] = data["author"] - self.author = GuildMember(self, member_data) - else: - self.author = User(self, data["author"]) if data.get("author") else None - - self.content: Optional[str] = data.get("content") - self.timestamp: datetime.datetime = datetime.datetime.fromisoformat( - data["timestamp"] - ) - self.edited_timestamp: Optional[str] = ( - datetime.datetime.fromisoformat(data["edited_timestamp"]) # type: ignore - if data.get("edited_timestamp") - else None - ) - self.tts: bool = data["tts"] - self.mention_everyone: bool = data["mention_everyone"] - self.mentions: Optional[List[User]] = [ - User(client, user) for user in data.get("mentions", []) - ] - self.mention_roles: Optional[List[int]] = ( - [int(r) for r in data["mention_roles"]] - if data.get("mention_roles") - else None - ) - self.mention_channels: Optional[List[MentionedChannel]] = [ - MentionedChannel(channel) for channel in data.get("mention_channels", []) - ] - self.embeds: Optional[List[Embed]] = [ - Embed.from_dict(embed) for embed in data.get("embeds", []) - ] - self.reactions: Optional[List[Reaction]] = [ - Reaction(reaction) for reaction in data.get("reactions", []) - ] - self.nonce: Optional[Union[int, str]] = data.get("nonce") - self.pinned: bool = data["pinned"] - self.type: int = data["type"] - self.activity: Optional[MessageActivity] = ( - MessageActivity(data["activity"]) if data.get("activity") else None - ) - # * Despite there being a PartialApplication, - # * Discord don't specify what attributes it has - self.application: Optional[Application] = ( - Application(data["application"]) if data.get("application") else None - ) - self.flags: Optional[int] = data.get("flags") - self.referenced_message: Optional[Message] = ( - Message(client, data["referenced_message"]) # type: ignore # MyPy being dumb - if data.get("referenced_message") - else None - ) - self.message_reference: Optional[MessageReference] = ( - MessageReference(data["message_reference"]) - if data.get("message_reference") - else None - ) - from .interactions import MessageInteraction - - self.interaction: Optional[MessageInteraction] = ( - MessageInteraction(client, data["interaction"]) - if data.get("interaction") - else None - ) - self.thread: Optional[Thread] = ( - Thread(self.client, data["thread"]) if data.get("thread") else None - ) - self.components: Optional[List[Union[TextInput, SelectMenu, Button]]] = ( - [ActionRow.from_dict(component) for component in data["components"]] - if data.get("components") - else None - ) - - self.sticker_items: Optional[List[StickerItem]] = ( - [StickerItem(sticker) for sticker in data["sticker_items"]] - if data.get("sticker_items") - else None - ) - - self.channel = client.channels.get(self.channel_id) - - async def add_reaction(self, emoji: str): - emoji = _quote(emoji) - response = await self.client.http.put( - f"channels/{self.channel_id}/messages/{self.id}/reactions/{emoji}/@me" - ) - return await response.json() - - async def remove_reaction(self, emoji: str, user=None): - emoji = _quote(emoji) - response = ( - await self.client.http.delete( - f"channels/{self.channel_id}/messages/{self.id}/reactions/{emoji}/{user.id}" - ) - if user - else await self.client.http.delete( - f"channels/{self.channel_id}/messages/{self.id}/reactions/{emoji}/@me" - ) - ) - - return await response.json() - - async def fetch_reactions(self, *, after, limit) -> List[Reaction]: - response = await self.client.http.get( - f"channels/{self.channel_id}/messages/{self.id}/reactions?after={after}&limit={limit}" - ) - return await response.json() - - async def delete_all_reactions(self): - response = await self.client.http.delete( - f"channels/{self.channel_id}/messages/{self.id}/reactions" - ) - return await response.json() - - async def delete_reaction_for_emoji(self, emoji: str): - emoji = _quote(emoji) - response = await self.client.http.delete( - f"channels/{self.channel_id}/messages/{self.id}/reactions/{emoji}" - ) - return await response.json() - - async def edit(self, message_data: dict): - response = await self.client.http.patch( - f"channels/{self.channel_id}/messages/{self.id}", data=message_data - ) - return await response.json() - - async def delete(self, reason: Optional[str] = None): - response = await self.client.http.delete( - f"channels/{self.channel_id}/messages/{self.id}", reason=reason - ) - return await response.json() - - async def pin(self, *, reason: Optional[str] = None): - response = await self.client.http.put( - f"channels/{self.channel_id}/pins/{self.id}", reason=reason - ) - return await response.json() - - async def unpin(self, *, reason: Optional[str] = None): - response = await self.client.http.delete( - f"channels/{self.channel_id}/pins/{self.id}", reason=reason - ) - return await response.json() - - async def start_thread( - self, - name: str, - auto_archive_duration: Optional[int] = None, - rate_limit_per_user: Optional[int] = None, - ): - payload = Utils.filter_values( - { - "name": name, - "auto_archive_duration": auto_archive_duration, - "rate_limit_per_user": rate_limit_per_user, - } - ) - - response = await self.client.http.post( - f"channels/{self.channel_id}/messages/{self.id}/threads", - json=payload, - ) - # * Cache it - thread = Thread(self.client, await response.json()) - self.client.guilds[self.guild_id].channels[thread.id] = thread - return thread - - async def crosspost(self): - response = await self.client.http.post( - f"channels/{self.channel_id}/messages/{self.id}/crosspost" - ) - return await response.json() - - -class MessagePayload(TypedDict): - content: NotRequired[str] - nonce: NotRequired[Union[int, str]] - tts: NotRequired[bool] - embeds: NotRequired[List[discord_typings.EmbedData]] - allowed_mentions: NotRequired[discord_typings.AllowedMentionsData] - message_reference: NotRequired[discord_typings.MessageReferenceData] - components: NotRequired[List[discord_typings.ActionRowData]] - sticker_ids: NotRequired[List[int]] - attachments: NotRequired[List[discord_typings.AttachmentData]] - flags: NotRequired[int] - - -__all__ = ( - "AllowedMention", - "MessageActivity", - "Attachment", - "Reaction", - "Embed", - "File", - "Message", - "MessagePayload", - "MessageReference", -) diff --git a/EpikCord/opcodes.py b/EpikCord/opcodes.py deleted file mode 100644 index 92c349ce..00000000 --- a/EpikCord/opcodes.py +++ /dev/null @@ -1,41 +0,0 @@ -from enum import IntEnum - - -class Opcode(IntEnum): - ... - - -class GatewayOpcode(Opcode): - DISPATCH = 0 - HEARTBEAT = 1 - IDENTIFY = 2 - PRESENCE_UPDATE = 3 - VOICE_STATE_UPDATE = 4 - - RESUME = 6 - RECONNECT = 7 - - REQUEST_GUILD_MEMBERS = 8 - INVALID_SESSION = 9 - - HELLO = 10 - HEARTBEAT_ACK = 11 - - -class VoiceOpcode(Opcode): - IDENTIFY = 0 - SELECT_PROTOCOL = 1 - READY = 2 - HEARTBEAT = 3 - SESSION_DESCRIPTION = 4 - SPEAKING = 5 - - HEARTBEAT_ACK = 6 - RESUME = 7 - HELLO = 8 - RESUMED = 9 - - CLIENT_DISCONNECT = 13 - - -__all__ = ("GatewayOpcode", "VoiceOpcode") diff --git a/EpikCord/options.py b/EpikCord/options.py deleted file mode 100644 index f415c7c1..00000000 --- a/EpikCord/options.py +++ /dev/null @@ -1,247 +0,0 @@ -from __future__ import annotations - -from typing import List, Optional, Union - -from EpikCord.exceptions import InvalidData - -from .abstract import BaseSlashCommandOption -from .localizations import Localization -from .type_enums import ChannelType - - -class StringOption(BaseSlashCommandOption): - def __init__( - self, - *, - name: str, - description: str, - required: bool = True, - autocomplete: Optional[bool] = False, - min_length: Optional[int] = None, - max_length: Optional[int] = None, - ): - super().__init__(name=name, description=description, required=required) - self.type = 3 - self.min_length = min_length - self.max_length = max_length - self.autocomplete = autocomplete - - def to_dict(self): - usual_dict = super().to_dict() - usual_dict["autocomplete"] = self.autocomplete - - if self.min_length: - usual_dict["min_length"] = self.min_length - if self.max_length: - usual_dict["max_length"] = self.max_length - - return usual_dict - - -class IntegerOption(BaseSlashCommandOption): - def __init__( - self, - *, - name: str, - description: str, - required: bool = True, - autocomplete: Optional[bool] = False, - min_value: Optional[int] = None, - max_value: Optional[int] = None, - ): - super().__init__(name=name, description=description, required=required) - self.type = 4 - self.autocomplete = autocomplete - self.min_value = min_value - self.max_value = max_value - - def to_dict(self): - usual_dict = super().to_dict() - usual_dict["autocomplete"] = self.autocomplete - if self.min_value: - usual_dict["min_value"] = self.min_value - if self.max_value: - usual_dict["max_value"] = self.max_value - return usual_dict - - -class BooleanOption(BaseSlashCommandOption): - def __init__(self, *, name: str, description: str, required: bool = True): - super().__init__(name=name, description=description, required=required) - self.type = 5 - - -class UserOption(BaseSlashCommandOption): - def __init__(self, *, name: str, description: str, required: bool = True): - super().__init__(name=name, description=description, required=required) - self.type = 6 - - -class ChannelOption(BaseSlashCommandOption): - def __init__(self, *, name: str, description: str, required: bool = True): - super().__init__(name=name, description=description, required=required) - self.type = 7 - self.channel_types: List[ChannelType] = [] - - def to_dict(self): - usual_dict: dict = super().to_dict() - usual_dict["channel_types"] = self.channel_types - return usual_dict - - -class RoleOption(BaseSlashCommandOption): - def __init__(self, *, name: str, description: str, required: bool = True): - super().__init__(name=name, description=description, required=required) - self.type = 8 - - -class MentionableOption(BaseSlashCommandOption): - def __init__(self, *, name: str, description: str, required: bool = True): - super().__init__(name=name, description=description, required=required) - self.type = 9 - - -class NumberOption(BaseSlashCommandOption): - def __init__( - self, - *, - name: str, - description: str, - required: bool = True, - autocomplete: Optional[bool] = False, - min_value: Optional[int] = None, - max_value: Optional[int] = None, - ): - super().__init__(name=name, description=description, required=required) - self.type = 10 - self.autocomplete = autocomplete - self.min_value = min_value - self.max_value = max_value - - def to_dict(self): - usual_dict = super().to_dict() - usual_dict["autocomplete"] = self.autocomplete - if self.min_value: - usual_dict["min_value"] = self.min_value - if self.max_value: - usual_dict["max_value"] = self.max_value - return usual_dict - - -class AttachmentOption(BaseSlashCommandOption): - def __init__(self, *, name: str, description: str, required: bool = True): - super().__init__(name=name, description=description, required=required) - self.type = 11 - - -class SlashCommandOptionChoice: - def __init__( - self, - *, - name: str, - value: Union[float, int, str], - name_localization: List[Localization] = [], - ): - self.name: str = name - self.value: Union[float, int, str] = value - self.name_localizations: List[Localization] = [ - l.to_dict() for l in name_localization - ] - self.name_localisations = self.name_localizations - - def to_dict(self): - return {"name": self.name, "value": self.value} - - -class Subcommand(BaseSlashCommandOption): - def __init__( - self, - *, - name: str, - description: str, - options: Optional[List[AnyOption]] = None, - ): - super().__init__(name=name, description=description) - self.type = 1 - self.options = options or [] - for option in self.options: - if option.type == 1: - raise InvalidData("You cannot have a subcommand in a subcommand group.") - elif option.type == 2: - raise InvalidData( - "You cannot have a subcommand group in a subcommand group." - ) - - def to_dict(self): - usual_dict = super().to_dict() - usual_dict.pop("required", None) - usual_dict["options"] = [option.to_dict() for option in self.options] - return usual_dict - - -class SubCommandGroup(BaseSlashCommandOption): - def __init__( - self, - *, - name: str, - description: str, - options: Optional[List[AnyOption]] = None, - ): - super().__init__(name=name, description=description) - self.type = 2 - self.options = options or [] - for option in self.options: - if option.type == 2: - raise InvalidData( - "You cannot have a subcommand group in a subcommand group." - ) - - def to_dict(self): - usual_dict = super().to_dict() - usual_dict.pop("required", None) - usual_dict["options"] = [option.to_dict() for option in self.options] - return usual_dict - - -class ReceivedOption: - def __init__(self, data): - self.data = data - self.type = data["type"] - self.value: Optional[str] = data.get("value") - self.focused: Optional[bool] = data.get("focused") - self.options: Optional[List[ReceivedOption]] = ( - [ReceivedOption(data) for data in data["options"]] - if data.get("options") - else None - ) - - -AnyOption = Union[ - Subcommand, - SubCommandGroup, - StringOption, - IntegerOption, - BooleanOption, - UserOption, - ChannelOption, - RoleOption, - MentionableOption, - NumberOption, -] - -__all__ = ( - "Subcommand", - "SubCommandGroup", - "StringOption", - "IntegerOption", - "BooleanOption", - "UserOption", - "ChannelOption", - "RoleOption", - "MentionableOption", - "NumberOption", - "AttachmentOption", - "SlashCommandOptionChoice", - "AnyOption", - "ReceivedOption", -) diff --git a/EpikCord/partials.py b/EpikCord/partials.py deleted file mode 100644 index b8a63e80..00000000 --- a/EpikCord/partials.py +++ /dev/null @@ -1,66 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, List, Optional, Union - -from EpikCord.flags import Permissions - -if TYPE_CHECKING: - import discord_typings - - -class PartialEmoji: - def __init__(self, data): - self.data = data - self.name: str = data["name"] - self.id: str = data["id"] - self.animated: Optional[bool] = data.get("animated") - - def to_dict(self): - payload: discord_typings.EmojiData = { - "id": self.id, - "name": self.name, - } - - if self.animated is not None: - payload["animated"] = self.animated - - return payload - - -class PartialChannel: - def __init__(self, data: discord_typings.PartialChannelData): - self.data = data - self.id: int = int(data["id"]) - self.type: int = data["type"] - self.permissions: Optional[Permissions] = ( - Permissions(int(data["permissions"])) if data.get("permissions") else None - ) - - -class PartialUser: - def __init__( - self, data: discord_typings.UserData - ): # I can't find a PartialUser data type, doesn't exist? - self.data = data - self.id: int = int(data["id"]) - self.username: str = data["username"] - self.discriminator: str = data["discriminator"] - self.avatar: Optional[str] = data.get("avatar") - - -class PartialGuild: - def __init__( - self, data: Union[discord_typings.GuildData, discord_typings.PartialGuildData] - ): - self.data = data - self.id: int = int(data["id"]) - self.name: str = data["name"] - self.permissions: Optional[Permissions] = ( - Permissions(int(data["permissions"])) if data.get("permissions") else None - ) - self.features: Optional[List[discord_typings.GuildFeaturesData]] = data.get( - "features" - ) - - -__all__ = ("PartialEmoji", "PartialUser", "PartialGuild") diff --git a/EpikCord/presence.py b/EpikCord/presence.py deleted file mode 100644 index 1d6ebf46..00000000 --- a/EpikCord/presence.py +++ /dev/null @@ -1,144 +0,0 @@ -from typing import List, Optional - -from .exceptions import InvalidData, InvalidStatus - - -class Status: - """The class which represents a Status. - - Attributes - ---------- - status : str - The status of the user. - """ - - def __init__(self, status: str): - """Represents a Status. - - Parameters - ---------- - status : str - The status of the user. - Either ``online``, ``idle``, ``dnd`` or ``invisible``. - - Raises - ------ - InvalidStatus - The status that you supplied is not valid. - """ - if status not in {"online", "dnd", "idle", "invisible", "offline"}: - raise InvalidStatus("That is an invalid status.") - - self.status: str = status if status != "offline" else "invisible" - - -class Activity: - """Represents a Discord Activity object. - - Attributes - --------- - name : str - The name of the activity. - type : int - The type of the activity. - url : Optional[str] - The url of the activity. - Only available for the streaming activity - - """ - - def __init__(self, *, name: str, type: int, url: Optional[str] = None): - """Represents a Discord Activity object. - - Parameters - ---------- - name : str - The name of the activity. - type : int - The type of the activity. - url : Optional[str] - The url of the activity. - Only available for the streaming activity. - """ - self.name = name - self.type = type - self.url = url - - def to_dict(self): - """Returns activity class as dict - - Returns - ------- - payload : dict - The dict representation of the Activity. - - Raises - ------ - InvalidData - You tried to set an url for a non-streaming activity. - """ - payload = { - "name": self.name, - "type": self.type, - } - - if self.url: - if self.type != 1: - raise InvalidData("You cannot set a URL") - payload["url"] = self.url - - return payload - - -class Presence: - """ - A class representation of a Presence. - - Attributes - ---------- - activity : Optional[Activity] - The activity of the user. - status : Status - The status of the user. - """ - - def __init__( - self, - *, - activity: Optional[Activity] = None, - status: Optional[Status] = None, - ): - """ - Parameters - ---------- - activity : Optional[Activity] - The activity of the user. - status : Status - The status of the user. - """ - self.activity: Optional[Activity] = activity - self.status: Optional[str] = ( - status.status if isinstance(status, Status) else status - ) - - def to_dict(self): - """ - The dict representation of the Presence. - - Returns - ------- - payload : dict - The dict representation of the Presence. - """ - payload = {} - - if self.status: - payload["status"] = self.status - - if self.activity: - payload["activity"] = [self.activity.to_dict()] - - return payload - - -__all__ = ("Status", "Activity", "Presence") diff --git a/EpikCord/py.typed b/EpikCord/py.typed deleted file mode 100644 index e69de29b..00000000 diff --git a/EpikCord/rtp_handler.py b/EpikCord/rtp_handler.py deleted file mode 100644 index 0da486ed..00000000 --- a/EpikCord/rtp_handler.py +++ /dev/null @@ -1,79 +0,0 @@ -""" -I have taken this directly from https://github.com/nickvsnetworking/pyrtp/. -If you're Nick reading this, I would like to thank you for this. -I have no idea how to handle RPT Packets/headers and for me to find something like this is very helpful. -Check out Nick's website @ https://nickvsnetworking.com. -Again, not at all my code, this is all Nicks. -I do realise that there is no License in his repository, -""" - - -def generate_rtp_packet(rtp_params, packet_vars): - - ##Example Usage: - # payload = 'd5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5' - # packet_vars = {'version' : 2, 'padding' : 0, 'extension' : 0, 'csi_count' : 0, 'marker' : 0, 'payload_type' : 8, 'sequence_number' : 306, 'timestamp' : 306, 'ssrc' : 185755418, 'payload' : payload} - # GenerateRTPpacket(packet_vars) #Generates hex to send down the wire - - # The first twelve octets are present in every RTP packet, while the list of CSRC identifiers is present only when inserted by a mixer. - # Generate first byte of header as binary string: - version = str( - format(rtp_params["version"], "b").zfill(2) - ) # RFC189 Version (Typically 2) - padding = str(rtp_params["padding"]) # Padding (Typically false (0)) - extension = str(rtp_params["extension"]) # Extension - Disabled - csi_count = str( - format(rtp_params["csi_count"], "b").zfill(4) - ) # Contributing Source Identifiers Count (Typically 0) - byte1 = format(int((version + padding + extension + csi_count), 2), "x").zfill( - 2 - ) # Convert binary values to an int then format that as hex with 2 bytes of padding if requiredprint(byte1) - - # Generate second byte of header as binary string: - marker = str(rtp_params["marker"]) # Marker (Typically false) - payload_type = str( - format(rtp_params["payload_type"], "b").zfill(7) - ) # 7 bit Payload Type (From https://tools.ietf.org/html/rfc3551#section-6) - byte2 = format(int((marker + payload_type), 2), "x").zfill( - 2 - ) # Convert binary values to an int then format that as hex with 2 bytes of padding if required - - sequence_number = format(rtp_params["sequence_number"], "x").zfill( - 4 - ) # 16 bit sequence number (Starts from a random position and incriments per packet) - - timestamp = format(rtp_params["timestamp"], "x").zfill( - 8 - ) # (Typically incrimented by the fixed time between packets) - - ssrc = str( - format(rtp_params["ssrc"], "x").zfill(8) - ) # SSRC 32 bits (Typically randomly generated for each stream for uniqueness) - - payload = rtp_params["payload"] - - # print(packet) - return byte1 + byte2 + sequence_number + timestamp + ssrc + payload - - -def decode_rtp_packet(packet_bytes): - byte1 = format(int(packet_bytes[:2], 16), "b") - byte2 = format(int(packet_bytes[2:4], 16), "b").zfill(8) - return { - "version": int(byte1[:2], 2), - "padding": int(byte1[2:3]), - "extension": int(byte1[3:4]), - "csi_count": int(byte1[4:8], 2), - "marker": int(byte2[:1]), - "payload_type": int(byte2[1:8], 2), - "sequence_number": int(str(packet_bytes[4:8]), 16), - "timestamp": int(str(packet_bytes[8:16]), 16), - "ssrc": int(str(packet_bytes[16:24]), 16), - "payload": str(packet_bytes[24:]), - } - - -__all__ = ( - "decode_rtp_packet", - "generate_rtp_packet", -) diff --git a/EpikCord/sharding.py b/EpikCord/sharding.py deleted file mode 100644 index 351fb9f0..00000000 --- a/EpikCord/sharding.py +++ /dev/null @@ -1,133 +0,0 @@ -from __future__ import annotations - -import asyncio -from sys import platform -from typing import TYPE_CHECKING, List, Optional - -from .client import HTTPClient, WebsocketClient -from .flags import Intents -from .opcodes import GatewayOpcode -from .presence import Presence -from .utils import Utils - -if TYPE_CHECKING: - import discord_typings - - -class Shard(WebsocketClient): - def __init__( - self, - token: str, - intents: Intents, - shard_id, - number_of_shards, - presence: Optional[Presence] = None, - discord_endpoint: str = "https://discord.com/api/v10", - ): - super().__init__(token, intents, presence, discord_endpoint) - self.shard_id = [shard_id, number_of_shards] - - async def ready(self, data: dict): - self.session_id: str = data["session_id"] - - async def identify(self): - payload = { - "op": GatewayOpcode.IDENTIFY, - "d": { - "token": self.token, - "intents": self.intents.value, - "properties": { - "os": platform, - "browser": "EpikCord.py", - "device": "EpikCord.py", - }, - "shard": str(self.shard_id), - }, - } - - if self.presence: - payload["d"]["presence"] = self.presence.to_dict() - - await self.send_json(payload) - - async def reconnect(self): - await self.close() - await self.connect() - await self.identify() - await self.resume() - - -class ShardManager: - def __init__( - self, - token: str, - intents: Intents, - *, - shards: Optional[int] = None, - overwrite_commands_on_ready: bool = False, - discord_endpoint: Optional[str] = None, - presence: Optional[Presence] = None, - ): - super().__init__() - self.token: str = token - self.overwrite_commands_on_ready: bool = overwrite_commands_on_ready - from EpikCord import __version__ - - self.http: HTTPClient = HTTPClient( - token, - headers={ - "Authorization": f"Bot {token}", - "User-Agent": f"DiscordBot (https://github.com/EpikCord/EpikCord.py {__version__})", - }, - ) - self.intents: Intents = ( - intents if isinstance(intents, Intents) else Intents(intents) # type: ignore - ) - self.desired_shards: Optional[int] = shards - self.shards: List[Shard] = [] - self.presence: Optional[Presence] = presence - self.discord_endpoint: Optional[str] = discord_endpoint - - def run(self): - async def wrapper(): - endpoint_data = await self.http.get("/gateway/bot") # ClientResponse - endpoint_data = await endpoint_data.json() # Dict - - max_concurrency = endpoint_data["session_start_limit"]["max_concurrency"] - - shards = self.desired_shards or endpoint_data["shards"] - - for shard_id in range(shards): - self.shards.append( - Shard( - self.token, - self.intents, - shard_id, - shards, - self.presence, - self.discord_endpoint, - ) - ) - - current_iteration = 0 # The current shard_id we've run - - for shard in self.shards: - shard.events = self.events - await shard.login() - await shard.wait_for("ready") - - current_iteration += 1 - - if current_iteration == max_concurrency: - await asyncio.sleep(5) - current_iteration = 0 # Reset it - - if self.overwrite_commands_on_ready: - for shard in self.shards: - await Utils(shard).override_commands() - - loop = asyncio.get_event_loop() - loop.run_until_complete(wrapper()) - - -__all__ = ("Shard", "ShardManager") diff --git a/EpikCord/status_code.py b/EpikCord/status_code.py deleted file mode 100644 index f399c8d8..00000000 --- a/EpikCord/status_code.py +++ /dev/null @@ -1,272 +0,0 @@ -from __future__ import annotations - -from enum import IntEnum - - -class StatusCode(IntEnum): - pass - - -class HTTPCodes(StatusCode): - """HTTP response codes.""" - - OK = 200 - CREATED = 201 - - NO_CONTENT = 204 - NOT_MODIFIED = 304 - - BAD_REQUEST = 400 - UNAUTHORIZED = 401 - FORBIDDEN = 403 - NOT_FOUND = 404 - METHOD_NOT_ALLOWED = 405 - - TOO_MANY_REQUESTS = 429 - - GATEWAY_UNAVAILABLE = 502 - SERVER_ERROR = 500 - - @classmethod - def _missing_(cls, value: object) -> HTTPCodes: - if not isinstance(value, int): - raise ValueError(f"{value} is not a valid HTTP status code.") - - if value > HTTPCodes.SERVER_ERROR and value != HTTPCodes.GATEWAY_UNAVAILABLE: - return HTTPCodes.SERVER_ERROR - - raise ValueError( - f"HTTP Status Code `{value}` is undocumented.\n" - "Please create an issue at: https://github.com/EpikCord/EpikCord.py/issues" - " with the following traceback." - ) - - -class JsonErrorCodes(StatusCode): - GENERAL_ERROR = 0 - - UNKNOWN_ACCOUNT = 10001 - UNKNOWN_APPLICATION = 10002 - UNKNOWN_CHANNEL = 10003 - UNKNOWN_GUILD = 10004 - UNKNOWN_INTEGRATION = 10005 - UNKNOWN_INVITE = 10006 - UNKNOWN_MEMBER = 10007 - UNKNOWN_MESSAGE = 10008 - UNKNOWN_OVERWRITE = 10009 - UNKNOWN_PROVIDER = 10010 - UNKNOWN_ROLE = 10011 - UNKNOWN_TOKEN = 10012 - UNKNOWN_USER = 10013 - UNKNOWN_EMOJI = 10014 - UNKNOWN_WEBHOOK = 10015 - UNKNOWN_WEBHOOK_SERVICE = 10016 - - UNKNOWN_SESSION = 10020 - - UNKNOWN_BAN = 10026 - UNKNOWN_SKU = 10027 - - UNKNOWN_STORE_LISTING = 10028 - UNKNOWN_ENTITLEMENT = 10029 - - UNKNOWN_BUILD = 10030 - UNKNOWN_LOBBY = 10031 - - UNKNOWN_BRANCH = 10032 - UNKNOWN_STORE_DIRECTORY_LAYOUT = 10033 - - UNKNOWN_REDISTRIBUTABLE = 10036 - - UNKNOWN_GIFT_CODE = 10038 - - UNKNOWN_STREAM = 10049 - UNKNOWN_PREMIUM_SERVER_SUB_COOLDOWN = 10050 - - UNKNOWN_GUILD_TEMPLATE = 10057 - - UNKNOWN_DISCOVERABLE_SERVER_CATEGORY = 10059 - UNKNOWN_STICKER = 10060 - - UNKNOWN_INTERACTION = 10062 - UNKNOWN_APPLICATION_COMMAND = 10063 - - UNKNOWN_VOICE_STATE = 10065 - UNKNOWN_APP_COMMAND_PERMS = 10066 - UNKNOWN_STAGE_INSTANCE = 10067 - UNKNOWN_GUILD_MEMBER_VERIFICATION_FORM = 10068 - UNKNOWN_GUILD_WELCOME_SCREEN = 10069 - UNKNOWN_GUILD_SCHEDULED_EVENT = 10070 - UNKNOWN_GUILD_SCHEDULED_EVENT_USER = 10071 - - UNKNOWN_TAG = 10087 - - NOT_BOT_ENDPOINT = 20001 - ONLY_BOT_ENDPOINT = 20002 - - EXPLICIT_CONTENT_CANNOT_BE_SEND = 20009 - - APP_UNAUTHORIZED = 20012 - - SLOWMODE_LIMIT_FAILURE = 20016 - - ACCOUNT_OWNERSHIP_REQUIRED = 20018 - - ANNOUNCEMENT_RATE_LIMIT = 20022 - - CHANNEL_WRITE_RATE_LIMIT = 20028 - SERVER_ACTION_RATE_LIMIT = 20029 - - METHOD_NOT_ALLOWED = 20031 - - INSUFFICIENT_GUILD_PREMIUM_SUBSCRIPTION = 20035 - - MAX_GUILD_REACHED = 30001 - MAX_FRIENDS_REACHED = 30002 - MAX_PINS_REACHED = 30003 - MAX_RECIPIENT_REACHED = 30004 - MAX_GUILD_ROLE_REACHED = 30005 - - MAX_WEBHOOKS_REACHED = 30007 - MAX_EMOJIS_REACHED = 30008 - - MAX_REACTIONS_REACHED = 30010 - - MAX_GUILD_CHANNELS_REACHED = 30013 - - MAX_ATTACHMENTS_REACHED = 30015 - MAX_INVITE_CODES_REACHED = 30016 - - MAX_ANIMATED_EMOTES_REACHED = 30018 - MAX_SERVER_MEMBER_REACHED = 30019 - - MAX_SERVER_CATEGORY_REACHED = 30030 - GUILD_ALREADY_HAS_TEMPLATE = 30031 - - MAX_THREAD_PARTICIPANTS_REACHED = 30033 - - MAX_NON_GUILD_BANS_REACHED = 30035 - - MAX_BAN_FETCH_REACHED = 30037 - MAX_INCOMPLETE_GUILD_SCHEDULED_EVENTS_REACHED = 30038 - MAX_STICKER_REACHED = 30039 - MAX_MEMBER_PRUNE_REACHED = 30040 - - MAX_GUILD_WIDGET_UPDATE_REACHED = 30042 - - MAX_SUP_HOUR_EDIT = 30046 - MAX_PIN_THREAD_REACHED = 30047 - MAX_TAG_REACHED = 30048 - - UNAUTHORIZED_TOKEN = 40001 - ACCOUNT_VERIFICATION_NEEDED = 40002 - DIRECT_MESSAGE_RATE_LIMITED = 40003 - SEND_MESSAGE_DISABLED = 40004 - REQUEST_TOO_LARGE = 40005 - DISABLED_FEATURE = 40006 - BANNED_FROM_GUILD = 40007 - - NOT_CONNECTED_TO_VOICE = 40032 - ALREADY_CROSSPOSTED = 40033 - - APP_COMMAND_ALREADY_EXIST = 40041 - - INTERACTION_ALREADY_ACKNOWLEDGED = 40060 - - MISSING_ACCESS = 50001 - INVALID_ACCOUNT_TYPE = 50002 - DM_CHANNEL_ACTION_PROHIBITED = 50003 - GUILD_WIDGET_DISABLED = 50004 - MESSAGE_AUTHORED_BY_ANOTHER_USER = 50005 - EMPTY_MESSAGE = 50006 - USER_NOT_MESSAGABLE = 50007 - NON_TEXT_CHANNEL = 50008 - CHANNEL_VERIFICATION_TOO_HIGH = 50009 - OAUTH2_APPLICATION_BOT_AUTH_INVALID = 50010 - OAUTH2_APP_LIMIT_REACHED = 50011 - INVALID_OAUTH2_STATE = 50012 - ACTION_PERMISSION_MISSING = 50013 - INVALID_AUTH_TOKEN = 50014 - NOTE_TOO_LONG = 50015 - DELETE_OUT_OF_RANGE = 50016 - - INVALID_PIN_CHANNEL = 50019 - INVALID_INVITE_CODE = 50020 - ACTION_ON_SYSTEM_MESSAGE_PROHIBITED = 50021 - - ACTION_ON_WRONG_CHANNEL_TYPE = 50024 - OAUTH2_TOKEN_INVALID = 50025 - MISSING_OAUTH2_SCOPE = 50026 - INVALID_WEBHOOK_TOKEN = 50027 - INVALID_ROLE = 50028 - - INVALID_RECIPIENT = 50033 - MESSAGE_TOO_OLD = 50034 - INVALID_FORM_BODY = 50035 - INVITE_ACCEPTED_TO_GUILD_NOT_MEMBER = 50036 - - INVALID_API_VERSION = 50041 - - FILE_UPLOAD_SIZE_LIMIT_EXCEEDED = 50045 - INVALID_FILE_UPLOAD = 50046 - - CANNOT_SELF_REDEEM_GIFT = 50054 - INVALID_GUILD = 50055 - - INVALID_MESSAGE_TYPE = 50068 - - PAYMENT_SOURCE_REQUIRED = 50070 - - CHANNEL_REQUIRED_FOR_COMMUNITY_GUILD = 50074 - - CANNOT_EDIT_MESSAGE_STICKER = 50080 - INVALID_STICKER_SEND = 50081 - - INVALID_ACTION_ON_THREAD_ARCHIVED = 50083 - INVALID_THREAD_NOTIFICATION_SETTINGS = 50084 - BEFORE_DATE_EARLIER_THAN_THEAD_CREATION = 50085 - COMMUNITY_CHANNEL_MUST_BE_A_TEXT_CHANNEL = 50086 - - SERVER_NOT_AVAILABLE_FOR_LOCATION = 50095 - - SERVER_REQUIRED_MONETIZATION = 50097 - - SERVER_BOOTS_INSUFFICIENT = 50101 - - INVALID_JSON_BODY = 50109 - - STICKER_PERMISSION_MISSING = 50600 - TWO_FACTOR_AUTH_REQUIRED = 50603 - - DISCORD_TAG_UNALLOCATED = 80004 - - REACTION_BLOCKED = 90001 - - API_RESOURCE_OVERLOADED = 130000 - STAGE_ALREADY_OPEN = 150006 - - HISTORY_PERMISSION_MISSING_TO_REPLY = 160002 - - THREAD_ALREADY_CREATED_FOR_MESSAGE = 160004 - LOCKED_THREAD = 160005 - MAX_ACTIVE_THREADS_REACHED = 160006 - MAX_MEMBER_ACTIVE_ANNOUNCEMENTS_THREAD_REACHED = 160007 - - INVALID_JSON_LOTTIE_FILE = 170001 - RASTERIZED_IMG_IN_LOTTIE_FILE = 170002 - MAX_STICKER_FRAMERATE_REACHED = 170003 - STICKER_FRAME_COUNT_EXCEEDED = 170004 - LOTTIE_ANIMATION_DIMENSIONS_EXCEEDED = 170005 - STICKER_FRAME_RATE_OUT_OF_RANGE = 170006 - STICKER_ANIMATION_DURATION_EXCEEDED = 170007 - - CANNOT_UPLOAD_FINISHED_EVENT = 180000 - - STAGE_CREATION_ON_STAGE_EVENT_FAILED = 180002 - - @classmethod - def _missing_(cls, value: object) -> JsonErrorCodes: - return cls.GENERAL_ERROR - - -__all__ = ("HTTPCodes", "JsonErrorCodes") diff --git a/EpikCord/sticker.py b/EpikCord/sticker.py deleted file mode 100644 index 5427b446..00000000 --- a/EpikCord/sticker.py +++ /dev/null @@ -1,92 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, List, Optional - -from .type_enums import StickerFormatType, StickerType -from .user import User - -if TYPE_CHECKING: - import discord_typings - - -class StickerItem: - def __init__(self, data: discord_typings.StickerItemData): - self.id: int = int(data["id"]) - self.name: str = data["name"] - self.format_type: StickerFormatType = StickerFormatType(data["format_type"]) - - -class Sticker: - def __init__(self, client, data: discord_typings.StickerData): - self.client = client - self.id: int = int(data["id"]) - self.pack_id: Optional[int] = ( - int(data["pack_id"]) if data.get("pack_id") else None - ) - self.name: str = data["name"] - self.description: Optional[str] = data.get("description") - self.tags: List[str] = data["tags"].split(",") - self.type: StickerType = StickerType(data["type"]) - self.format_type: StickerFormatType = StickerFormatType(data["format_type"]) - self.available: Optional[bool] = data.get("available") - self.guild_id: Optional[int] = ( - int(data["guild_id"]) if data.get("guild_id") else None - ) - self.user: Optional[User] = ( - User(self.client, data["user"]) if data.get("user") else None - ) - self.sort_value: Optional[int] = data.get("sort_value") - - async def edit( - self, - *, - name: Optional[str] = None, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - ): - if not self.guild_id: - raise ValueError("Cannot edit a sticker that is not in a guild.") - payload = {} - if name: - payload["name"] = name - if description: - payload["description"] = description - if tags: - payload["tags"] = ",".join(tags) - await self.client.http.patch( - f"/guilds/{self.guild_id}/stickers/{self.id}", - json=payload, - guild_id=self.guild_id, - ) - return - - async def delete(self, reason: Optional[str] = None): - if not self.guild_id: - raise ValueError("Cannot delete a sticker that is not in a guild.") - await self.client.http.delete( - f"/guilds/{self.guild_id}/stickers/{self.id}", - reason=reason, - guild_id=self.guild_id, - ) - return - - -class StickerPack: - def __init__(self, client, data: dict): - self.client = client - self.id: int = int(data["id"]) - self.stickers: List[Sticker] = [ - Sticker(self.client, s) for s in data["stickers"] - ] - self.name: str = data["name"] - self.sku_id: int = int(data["sku_id"]) - self.cover_sticker_id: Optional[int] = ( - int(data["cover_sticker_id"]) if data.get("cover_sticker_id") else None - ) - self.description: str = data["description"] - self.banner_asset_id: Optional[int] = ( - int(data["banner_asset_id"]) if data.get("banner_asset_id") else None - ) - - -__all__ = ("StickerItem", "Sticker") diff --git a/EpikCord/thread.py b/EpikCord/thread.py deleted file mode 100644 index df2788b8..00000000 --- a/EpikCord/thread.py +++ /dev/null @@ -1,123 +0,0 @@ -from __future__ import annotations - -import datetime -from typing import TYPE_CHECKING, List, Optional - -from .abstract import Messageable -from .exceptions import NotFound404, ThreadArchived -from .type_enums import VideoQualityMode - -if TYPE_CHECKING: - import discord_typings - - -class ThreadMember: - def __init__(self, data: discord_typings.ThreadMemberData): - self.id: int = int(data["user_id"]) - self.thread_id: int = int(data["id"]) - self.join_timestamp: datetime.datetime = datetime.datetime.fromisoformat( - data["join_timestamp"] - ) - self.flags: int = data[ - "flags" - ] # Currently I don't know what the Flag is, so it's int for now. - - -class ThreadMetaData: - def __init__(self, data: discord_typings.ThreadMetadataData): - self.archived: bool = data["archived"] - self.auto_archive_duration: int = data["auto_archive_duration"] - self.archive_timestamp: datetime.datetime = datetime.datetime.fromisoformat( - data["archive_timestamp"] - ) - self.locked: bool = data["locked"] - self.invitable: Optional[bool] = data.get("invitable") - self.create_timestamp: Optional[datetime.datetime] = ( - datetime.datetime.fromisoformat(data["create_timestamp"]) # type: ignore # Will be removed after PR is accepted in discord_typings - if data.get("create_timestamp") - else None - ) - - -class Thread(Messageable): - def __init__(self, client, data: discord_typings.ThreadChannelData): - super().__init__(client, int(data["id"])) - - self.owner_id: int = int(data["owner_id"]) - self.message_count: Optional[int] = data.get("message_count") - self.member_count: Optional[int] = data.get("member_count") - self.metadata: ThreadMetaData = ThreadMetaData(data["thread_metadata"]) - - async def join(self): - if self.archived: - raise ThreadArchived( - "This thread has been archived so it is no longer joinable" - ) - await self.client.http.put( - f"/channels/{self.id}/thread-members/@me", channel_id=self.id - ) - - async def add_member(self, member_id: str): - if self.metadata.archived: - raise ThreadArchived( - "This thread has been archived so it is no longer joinable" - ) - - response = await self.client.http.put( - f"/channels/{self.id}/thread-members/{member_id}", channel_id=self.id - ) - return await response.json() - - async def leave(self): - if self.archived: - raise ThreadArchived( - "This thread has been archived so it is no longer leaveable" - ) - response = await self.client.http.delete( - f"/channels/{self.id}/thread-members/@me", channel_id=self.id - ) - return await response.json() - - async def remove_member(self, member_id: str): - if self.metadata.archived: - raise ThreadArchived( - "This thread has been archived so it is no longer leaveable" - ) - - response = await self.client.http.delete( - f"/channels/{self.id}/thread-members/{member_id}", channel_id=self.id - ) - return await response.json() - - async def fetch_member(self, member_id: str) -> ThreadMember: - from EpikCord import ThreadMember - - response = await self.client.http.get( - f"/channels/{self.id}/thread-members/{member_id}", channel_id=self.id - ) - if response.status == 404: - raise NotFound404("The member you are trying to fetch does not exist") - return ThreadMember(await response.json()) - - async def list_members(self) -> List[ThreadMember]: - from EpikCord import ThreadMember - - response = await self.client.http.get( - f"/channels/{self.id}/thread-members", channel_id=self.id - ) - return [ThreadMember(member) for member in await response.json()] - - async def bulk_delete( - self, message_ids: List[int], reason: Optional[str] = None - ) -> None: - - response = await self.client.http.post( - f"channels/{self.id}/messages/bulk-delete", - data={"messages": message_ids}, - reason=reason, - channel_id=self.id, - ) - return await response.json() - - -__all__ = ("Thread", "ThreadMember") diff --git a/EpikCord/type_enums.py b/EpikCord/type_enums.py deleted file mode 100644 index e65d85ff..00000000 --- a/EpikCord/type_enums.py +++ /dev/null @@ -1,165 +0,0 @@ -from enum import Enum, IntEnum - - -class VisibilityType(IntEnum): - NONE = 0 - EVERYONE = 1 - - -class ApplicationCommandPermissionType(IntEnum): - ROLE = 1 - USER = 2 - CHANNEL = 3 - - -class Locale(Enum): - DANISH = "da" - GERMAN = "de" - ENGLISH_GB = "en-GB" - ENGLISH_US = "en-US" - SPANISH = "es-ES" - FRENCH = "fr" - CROATIAN = "hr" - ITALIAN = "it" - LITHUANIAN = "lt" - HUNGARIAN = "hu" - DUTCH = "nl" - NORWEGIAN = "no" - POLISH = "pl" - PORTUGUESE_BR = "pt-BR" - ROMANIAN = "ro" - FINNISH = "fi" - SWEDISH_SE = "sv-SE" - VIETNAMESE = "vi" - TURKISH = "tr" - CZECH = "cs" - GREEK = "el" - BULGARIAN = "bg" - RUSSIAN = "ru" - UKRAINE = "uk" - HINDI = "hi" - THAI = "th" - CHINESE_CN = "zh-CN" - JAPANESE = "ja" - CHINESE_TW = "zh-TW" - KOREAN = "ko" - - -class AutoModActionType(IntEnum): - BLOCK_MESSAGE = 1 - SEND_ALERT_MESSAGE = 2 - TIMEOUT = 3 - - -class AutoModEventType(IntEnum): - MESSAGE_SEND = 1 - - -class AutoModTriggerType(IntEnum): - KEYWORD = 1 - SPAM = 3 - KEYWORD_PRESENT = 4 - MENTION_SPAM = 5 - - -class AutoModKeywordPresetType(IntEnum): - PROFANITY = 1 - SEXUAL_CONTENT = 2 - SLURS = 3 - - -class ChannelType(IntEnum): - GUILD_TEXT = 0 - DM = 1 - GUILD_VOICE = 2 - GUILD_CATEGORY = 4 - GUILD_NEWS = 5 - GUILD_NEWS_THREAD = 10 - GUILD_PUBLIC_THREAD = 11 - GUILD_PRIVATE_THREAD = 12 - GUILD_STAGE_VOICE = 13 - GUILD_DIRECTORY = 14 - GUILD_FORUM = 15 - - -class StickerType(IntEnum): - STANDARD = 1 - GUILD = 2 - - -class StickerFormatType(IntEnum): - PNG = 1 - APNG = 2 - LOTTIE = 3 - - -class VerificationLevel(IntEnum): - NONE = 0 - LOW = 1 - MEDIUM = 2 - HIGH = 3 - VERY_HIGH = 4 - - -class PremiumTier(IntEnum): - NONE = 0 - TIER_1 = 1 - TIER_2 = 2 - TIER_3 = 3 - - -class NSFWLevel(IntEnum): - DEFAULT = 0 - EXPLICIT = 1 - SAFE = 2 - AGE_RESTRICTED = 3 - - -class AllowedMentionTypes(Enum): - ROLE = "roles" - USER = "users" - EVERYONE = "everyone" - ALL = ["roles", "users", "everyone"] - - -class GuildScheduledEventStatus(IntEnum): - SCHEDULED = 1 - ACTIVE = 2 - COMPLETED = 3 - CANCELED = 4 - - -class IntegrationExpireBehavior(IntEnum): - REMOVE_ROLE = 0 - KICK = 1 - - -class GuildScheduledEventPrivacyLevel(IntEnum): - GUILD_ONLY = 2 - - -class VideoQualityMode(IntEnum): - AUTO = 1 - FULL = 2 - - -__all__ = ( - "VisibilityType", - "ApplicationCommandPermissionType", - "Locale", - "AutoModActionType", - "AutoModEventType", - "StickerFormatType", - "StickerType", - "AutoModTriggerType", - "AutoModKeywordPresetType", - "ChannelType", - "VerificationLevel", - "PremiumTier", - "NSFWLevel", - "AllowedMentionTypes", - "GuildScheduledEventStatus", - "IntegrationExpireBehavior", - "GuildScheduledEventPrivacyLevel", - "VideoQualityMode", -) diff --git a/EpikCord/user.py b/EpikCord/user.py deleted file mode 100644 index 41f1017c..00000000 --- a/EpikCord/user.py +++ /dev/null @@ -1,38 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Optional - -from .abstract import Messageable - -if TYPE_CHECKING: - import discord_typings - - -class User(Messageable): - def __init__(self, client, data: discord_typings.UserData): - super().__init__(client, int(data["id"])) - self.data = data - self.client = client - self.id: int = int(data["id"]) - self.username: str = data["username"] - self.discriminator: str = data["discriminator"] - self.avatar: Optional[str] = data.get("avatar") - self.bot: Optional[bool] = data.get("bot") - self.system: Optional[bool] = data.get("system") - self.mfa_enabled: Optional[bool] = data.get("mfa_enabled") - self.banner: Optional[str] = data.get("banner") - # * the user's banner color encoded as an integer representation of - # * hexadecimal color code - self.accent_color: Optional[int] = data.get("accent_color") - self.locale: Optional[str] = data.get("locale") - self.verified: Optional[bool] = data.get("verified") - self.email: Optional[str] = data.get("email") # type: ignore - self.flags: Optional[int] = data.get("flags") - self.premium_type: Optional[int] = data.get("premium_type") - self.public_flags: Optional[int] = data.get("public_flags") - - def to_dict(self) -> discord_typings.UserData: - return self.data - - -__all__ = ("User",) diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py deleted file mode 100644 index 97224353..00000000 --- a/EpikCord/utils/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .command_utils import * -from .paginator import * -from .utils import * diff --git a/EpikCord/utils/command_utils.py b/EpikCord/utils/command_utils.py deleted file mode 100644 index b1b0260b..00000000 --- a/EpikCord/utils/command_utils.py +++ /dev/null @@ -1,18 +0,0 @@ -from ..client import CommandHandler - - -class CommandUtils(CommandHandler): - @staticmethod - def check(callback): - from EpikCord import Check - - return Check(callback) - - @staticmethod - def event(name: str): - from EpikCord import Event - - def wrapper(callback): - return Event(callback, event_name=name) - - return wrapper diff --git a/EpikCord/utils/paginator.py b/EpikCord/utils/paginator.py deleted file mode 100644 index 7dfaf741..00000000 --- a/EpikCord/utils/paginator.py +++ /dev/null @@ -1,52 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, List - -if TYPE_CHECKING: - from ..message import Embed - - -class Paginator: - def __init__(self, *, pages: List[Embed]): - self.current_index: int = 0 - self.__pages = pages - - def __iter__(self): - return self - - def __len__(self): - return len(self.__pages) - - def __next__(self): - return self.forward() - - @property - def page(self): - return self.__pages[self.current_index] - - def forward(self): - self.current_index = min(len(self.__pages), self.current_index + 1) - return self.__pages[self.current_index] - - def back(self): - self.current_index = max(0, self.current_index - 1) - return self.__pages[self.current_index] - - def first(self): - self.current_index = 0 - - def last(self): - self.current_index = len(self.__pages) - - def add_page(self, page: Embed): - self.__pages.append(page) - - def insert_page(self, page: Embed, index: int): - if index >= len(self.__pages): - self.add_page(page) - return - - self.__pages.index(page, index) - - def remove_page(self, page: Embed): - self.__pages = list(filter(lambda embed: embed != page, self.__pages)) diff --git a/EpikCord/utils/utils.py b/EpikCord/utils/utils.py deleted file mode 100644 index ec5aa393..00000000 --- a/EpikCord/utils/utils.py +++ /dev/null @@ -1,285 +0,0 @@ -from __future__ import annotations - -import asyncio -import datetime -import re -from base64 import b64encode -from collections import defaultdict -from logging import getLogger -from typing import TYPE_CHECKING, Callable, Optional, TypeVar, Union - -from ..channels import * -from ..components import * -from ..exceptions import InvalidArgumentType -from ..interactions import ( - ApplicationCommandInteraction, - AutoCompleteInteraction, - ButtonInteraction, - ModalSubmitInteraction, - SelectMenuInteraction, -) -from ..thread import Thread - -if TYPE_CHECKING: - import discord_typings - - from ..client import WebsocketClient - -logger = getLogger(__name__) -T = TypeVar("T") - - -class Utils: - """ - A utility class, used to make difficult things easy. - - Attributes: - ----------- - client: Client - The client that this utility class is attached to. - - """ - - channels_types = { - 0: GuildTextChannel, - 1: DMChannel, - 2: VoiceChannel, - 4: CategoryChannel, - 5: GuildAnnouncementChannel, - 10: Thread, - 11: Thread, - 13: GuildStageChannel, - 15: ForumChannel, - } - - def __init__(self, client: WebsocketClient): - self.client: WebsocketClient = client - self._MARKDOWN_ESCAPE_SUBREGEX = "|".join( - r"\{0}(?=([\s\S]*((?(?:>>)?\s|\[.+\]\(.+\)" - - self._MARKDOWN_ESCAPE_REGEX = re.compile( - rf"(?P{self._MARKDOWN_ESCAPE_SUBREGEX}|{self._MARKDOWN_ESCAPE_COMMON})", - re.MULTILINE, - ) - - self._URL_REGEX = ( - r"(?P<[^: >]+:\/[^ >]+>|(?:https?|steam):\/\/[^\s<]+[^<.,:;\"\'\]\s])" - ) - - self._MARKDOWN_STOCK_REGEX = ( - rf"(?P[_\\~|\*`]|{self._MARKDOWN_ESCAPE_COMMON})" - ) - - @staticmethod - def filter_values(dictionary: dict): - return {k: v for k, v in dictionary.items() if v is not None} - - async def override_commands(self): - command_sorter = defaultdict(list) - - for command in self.client.commands.values(): - command_payload = {"name": command.name, "type": command.type} - - if command_payload["type"] == 1: - command_payload["description"] = command.description - command_payload["options"] = [ - option.to_dict() for option in command.options - ] - - if command.name_localizations: - command_payload["name_localizations"] = {} - for name_localization in command.name_localizations: - if not name_localization: - return - command_payload["name_localizations"][ - name_localization - ] = name_localization.to_dict() - - if command.description_localizations: - command_payload["description_localizations"] = {} - for description_localization in command.description_localizations: - if not description_localization: - return - command_payload["description_localizations"][ - description_localization - ] = description_localization.to_dict() - - if command.guild_ids: - for guild_id in command.guild_ids: - command_sorter[guild_id].append(command_payload) - else: - command_sorter["global"].append(command_payload) - - for guild_id, commands in command_sorter.items(): - if guild_id == "global": - await self.client.application.bulk_overwrite_global_application_commands( - commands - ) - continue - - await self.client.application.bulk_overwrite_guild_application_commands( - guild_id, commands - ) - - @staticmethod - def get_mime_type_for_image(data: bytes) -> str: - if data.startswith(b"\x89\x50\x4E\x47\x0D\x0A\x1A\x0A"): - return "image/png" - - if data[:3] == b"\xff\xd8\xff" or data[6:10] in (b"JFIF", b"Exif"): - return "image/jpeg" - - gif_header = (b"\x47\x49\x46\x38\x37\x61", b"\x47\x49\x46\x38\x39\x61") - if data.startswith(gif_header): - return "image/gif" - - if data.startswith(b"RIFF") and data[8:12] == b"WEBP": - return "image/webp" - - raise InvalidArgumentType("Unsupported image type given") - - @staticmethod - def bytes_to_base64_data(data: bytes) -> str: - fmt = "data:{mime};base64,{data}" - mime = Utils.get_mime_type_for_image(data) - b64 = b64encode(data).decode("ascii") - return fmt.format(mime=mime, data=b64) - - @staticmethod - def component_from_type(component_data: dict): - component_types = [Button, SelectMenu, TextInput] - component_type = component_data["type"] - - if component_type < 2 or component_type > 4: - logger.warning(f"Unknown component type: {component_type}") - return - - component_cls = component_types[component_type - 2] - return component_cls(**component_data) - - @staticmethod - def filter_values_dynamic(check: Callable, dictionary: dict): - """Returns a filtered dictionary of values that pass the check.""" - return {k: v for k, v in dictionary.items() if check(v)} - - @staticmethod - def match_mixed(variant_one: str, variant_two: str): - """Matches and returns a single output from two""" - return variant_one or variant_two - - @staticmethod - def interaction_from_type( - data: discord_typings.InteractionData, - ) -> Optional[ - Union[ - ApplicationCommandInteraction, - ButtonInteraction, - SelectMenuInteraction, - AutoCompleteInteraction, - ModalSubmitInteraction, - ] - ]: - interaction_types = { - 2: ApplicationCommandInteraction, - 3: lambda client, data: SelectMenuInteraction(client, data) - if data["data"].get("values") - else ButtonInteraction(client, data), - 4: AutoCompleteInteraction, - 5: ModalSubmitInteraction, - } - - interaction_type = data["type"] - interaction_cls = interaction_types.get(interaction_type) - - if not interaction_cls: - logger.warning(f"Unknown interaction type: {interaction_type}") - return None - - return interaction_cls(self.client, data) # type: ignore - - def channel_from_type(self, channel_data: discord_typings.ChannelData): - channel_type = channel_data["type"] - - if channel_cls := self.channels_types.get(channel_type): - return channel_cls(self.client, channel_data) - - raise InvalidArgumentType(f"Unknown channel type: {channel_type}") - - @staticmethod - def compute_timedelta(dt: datetime.datetime): - if dt.tzinfo is None: - dt = dt.astimezone() - now = datetime.datetime.now(datetime.timezone.utc) - return max((dt - now).total_seconds(), 0) - - async def sleep_until( - self, when: Union[datetime.datetime, int, float], result: Optional[T] = None - ) -> Optional[T]: - if when == datetime.datetime: - delta = self.compute_timedelta(when) # type: ignore - - return await asyncio.sleep(delta if when == datetime.datetime else when, result) - - def remove_markdown(self, text: str, *, ignore_links: bool = True) -> str: - def replacement(match): - groupdict = match.groupdict() - return groupdict.get("url", "") - - regex = self._MARKDOWN_STOCK_REGEX - if ignore_links: - regex = f"(?:{self._URL_REGEX}|{regex})" - return re.sub(regex, replacement, text, 0, re.MULTILINE) - - def escape_markdown( - self, text: str, *, as_needed: bool = False, ignore_links: bool = True - ) -> str: - if as_needed: - text = re.sub(r"\\", r"\\\\", text) - return self._MARKDOWN_ESCAPE_REGEX.sub(r"\\\1", text) - - def replacement(match): - grouping = match.groupdict() - - if is_url := grouping.get("url"): - return is_url - - return "\\" + grouping["markdown"] - - regex = self._MARKDOWN_STOCK_REGEX - if ignore_links: - regex = f"(?:{self._URL_REGEX}|{regex})" - - return re.sub(regex, replacement, text, 0, re.MULTILINE) - - @staticmethod - def escape_mentions(text: str) -> str: - return re.sub(r"@(everyone|here|[!&]?\d{17,20})", "@\u200b\\1", text) - - @staticmethod - def utcnow() -> datetime.datetime: - return datetime.datetime.now(datetime.timezone.utc) - - @staticmethod - def cancel_tasks(loop) -> None: - tasks = {t for t in asyncio.all_tasks(loop=loop) if not t.done()} - - if not tasks: - return - - for task in tasks: - task.cancel() - logger.debug(f"Cancelled {len(tasks)} tasks") - loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True)) - - @staticmethod - def cleanup_loop(loop) -> None: - try: - Utils.cancel_tasks(loop) - logger.debug("Shutting down async generators.") - loop.run_until_complete(loop.shutdown_asyncgens()) - finally: - loop.close() diff --git a/EpikCord/voice.py b/EpikCord/voice.py deleted file mode 100644 index e1779ab6..00000000 --- a/EpikCord/voice.py +++ /dev/null @@ -1,47 +0,0 @@ -from __future__ import annotations - -import datetime -from typing import TYPE_CHECKING, Optional - -if TYPE_CHECKING: - import discord_typings - - -class VoiceRegion: - def __init__(self, data: discord_typings.VoiceRegionData): - self.id: str = data["id"] - self.name: str = data["name"] - self.optimal: bool = data["optimal"] - self.deprecated: bool = data["deprecated"] - self.custom: bool = data["custom"] - - -class VoiceState: - def __init__(self, client, data: discord_typings.VoiceStateData): - from EpikCord import GuildMember - - self.data = data - self.guild_id: Optional[int] = ( - int(data["guild_id"]) if data.get("guild_id") else None - ) - self.channel_id: int = int(data["channel_id"]) # type: ignore - self.user_id: int = int(data["user_id"]) - self.member: Optional[GuildMember] = ( - GuildMember(client, data["member"]) if data.get("member") else None - ) - self.session_id: str = data["session_id"] - self.deaf: bool = data["deaf"] - self.mute: bool = data["mute"] - self.self_deaf: bool = data["self_deaf"] - self.self_mute: bool = data["self_mute"] - self.self_stream: Optional[bool] = data.get("self_stream") - self.self_video: bool = data["self_video"] - self.suppress: bool = data["suppress"] - self.request_to_speak_timestamp: Optional[datetime.datetime] = ( - datetime.datetime.fromisoformat(data["request_to_speak_timestamp"]) # type: ignore - if data.get("request_to_speak_timestamp") - else None - ) - - -__all__ = ("VoiceRegion", "VoiceState") diff --git a/EpikCord/webhooks.py b/EpikCord/webhooks.py deleted file mode 100644 index fad48b9c..00000000 --- a/EpikCord/webhooks.py +++ /dev/null @@ -1,59 +0,0 @@ -from typing import Optional - -from discord_typings import PartialChannelData, UserData, WebhookData -from typing_extensions import TypedDict - -from .partials import PartialGuild - - -class WebhookUserData(TypedDict): - webhook_id: int - username: str - avatar: Optional[str] - - -class WebhookUser: - def __init__(self, data: UserData): - self.webhook_id: Optional[int] = data.get("webhook_id") # type: ignore - self.username: str = data["username"] - self.avatar: Optional[str] = data.get("avatar") - - -class SourceChannel: - def __init__(self, data: PartialChannelData): - self.id: int = int(data["id"]) - self.name: str = data["name"] - - -class Webhook: # Not used for making webhooks - def __init__(self, client, data: WebhookData): - self.id: int = int(data["id"]) - self.client = client - self.type: str = ( - "Incoming" - if data.get("type") == 1 - else "Channel Follower" - if data.get("type") == 2 - else "Application" - ) - self.guild_id: Optional[int] = int(data["guild_id"]) if data.get("guild_id") else None # type: ignore - self.channel_id: Optional[int] = int(data["channel_id"]) if data.get("channel_id") else None # type: ignore - self.user: Optional[WebhookUser] = ( - WebhookUser(data["user"]) if data.get("user") else None - ) - self.name: Optional[str] = data.get("name") - self.avatar: Optional[str] = data.get("avatar") - self.token: Optional[str] = data.get("token") - self.application_id: Optional[int] = int(data["application_id"]) if data.get("application_id") else None # type: ignore - self.source_guild: Optional[PartialGuild] = ( - PartialGuild(data["source_guild"]) if data.get("source_guild") else None - ) - self.source_channel: Optional[SourceChannel] = ( - SourceChannel(data["source_channel"]) - if data.get("source_channel") - else None - ) - self.url: Optional[str] = data.get("url") - - -__all__ = ("Webhook", "WebhookUser", "SourceChannel") diff --git a/EpikCord/ws_events.py b/EpikCord/ws_events.py deleted file mode 100644 index 0ddf1adf..00000000 --- a/EpikCord/ws_events.py +++ /dev/null @@ -1,61 +0,0 @@ -from __future__ import annotations - -from functools import partial -from typing import TYPE_CHECKING, Callable, Dict - -from .opcodes import GatewayOpcode - -if TYPE_CHECKING: - from .client import WebsocketClient - - -class WSEHandler: - @staticmethod - async def dispatch(ws_client, event_data): - ws_client.sequence = event_data["s"] - await ws_client.handle_event(event_data["t"].lower(), event_data["d"]) - - @staticmethod - async def heartbeat(ws_client, _event_data): - await ws_client.heartbeat(True) - - @staticmethod - async def reconnect(ws_client, _event_data): - await ws_client.reconnect() - await ws_client.resume() - - @staticmethod - async def invalid_session(ws_client, event_data): - if event_data["d"]: - await ws_client.reconnect() - await ws_client.resume() - return - - await ws_client.reconnect() - - @staticmethod - async def hello(ws_client, event_data): - ws_client.heartbeat_interval = event_data["d"]["heartbeat_interval"] / 1000 - await ws_client.identify() - - @staticmethod - def heartbeat_ack(ws_client, event_data): - ws_client.heartbeats.append(event_data) - - -# TODO: replace Dict with discord typing -WSEHandlerMapping = Dict[GatewayOpcode, Callable[[Dict], None]] - - -def setup_ws_event_handler(ws_client: WebsocketClient) -> WSEHandlerMapping: - return { - g_opcode: partial(method, ws_client) - for g_opcode, method in ( - (GatewayOpcode.DISPATCH, WSEHandler.dispatch), - (GatewayOpcode.HEARTBEAT, WSEHandler.heartbeat), - (GatewayOpcode.RECONNECT, WSEHandler.reconnect), - (GatewayOpcode.INVALID_SESSION, WSEHandler.invalid_session), - (GatewayOpcode.HELLO, WSEHandler.hello), - (GatewayOpcode.HEARTBEAT_ACK, WSEHandler.heartbeat_ack), - ) - } diff --git a/docs/EpikCord.application.rst b/docs/EpikCord.application.rst deleted file mode 100644 index ac6c8455..00000000 --- a/docs/EpikCord.application.rst +++ /dev/null @@ -1,37 +0,0 @@ -EpikCord.application package -============================ - -Submodules ----------- - -EpikCord.application.application module ---------------------------------------- - -.. automodule:: EpikCord.application.application - :members: - :undoc-members: - :show-inheritance: - -EpikCord.application.application\_command module ------------------------------------------------- - -.. automodule:: EpikCord.application.application_command - :members: - :undoc-members: - :show-inheritance: - -EpikCord.application.team module --------------------------------- - -.. automodule:: EpikCord.application.team - :members: - :undoc-members: - :show-inheritance: - -Module contents ---------------- - -.. automodule:: EpikCord.application - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/EpikCord.client.rst b/docs/EpikCord.client.rst deleted file mode 100644 index a56d6afe..00000000 --- a/docs/EpikCord.client.rst +++ /dev/null @@ -1,85 +0,0 @@ -EpikCord.client package -======================= - -Submodules ----------- - -EpikCord.client.client module ------------------------------ - -.. automodule:: EpikCord.client.client - :members: - :undoc-members: - :show-inheritance: - -EpikCord.client.client\_application module ------------------------------------------- - -.. automodule:: EpikCord.client.client_application - :members: - :undoc-members: - :show-inheritance: - -EpikCord.client.client\_user module ------------------------------------ - -.. automodule:: EpikCord.client.client_user - :members: - :undoc-members: - :show-inheritance: - -EpikCord.client.command\_handler module ---------------------------------------- - -.. automodule:: EpikCord.client.command_handler - :members: - :undoc-members: - :show-inheritance: - -EpikCord.client.http\_client module ------------------------------------ - -.. automodule:: EpikCord.client.http_client - :members: - :undoc-members: - :show-inheritance: - -EpikCord.client.sections module -------------------------------- - -.. automodule:: EpikCord.client.sections - :members: - :undoc-members: - :show-inheritance: - -EpikCord.client.user\_client module ------------------------------------ - -.. automodule:: EpikCord.client.user_client - :members: - :undoc-members: - :show-inheritance: - -EpikCord.client.webhook\_client module --------------------------------------- - -.. automodule:: EpikCord.client.webhook_client - :members: - :undoc-members: - :show-inheritance: - -EpikCord.client.websocket\_client module ----------------------------------------- - -.. automodule:: EpikCord.client.websocket_client - :members: - :undoc-members: - :show-inheritance: - -Module contents ---------------- - -.. automodule:: EpikCord.client - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/EpikCord.ext.rst b/docs/EpikCord.ext.rst deleted file mode 100644 index a0555b5a..00000000 --- a/docs/EpikCord.ext.rst +++ /dev/null @@ -1,21 +0,0 @@ -EpikCord.ext package -==================== - -Submodules ----------- - -EpikCord.ext.tasks module -------------------------- - -.. automodule:: EpikCord.ext.tasks - :members: - :undoc-members: - :show-inheritance: - -Module contents ---------------- - -.. automodule:: EpikCord.ext - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/EpikCord.managers.rst b/docs/EpikCord.managers.rst deleted file mode 100644 index a312cff3..00000000 --- a/docs/EpikCord.managers.rst +++ /dev/null @@ -1,53 +0,0 @@ -EpikCord.managers package -========================= - -Submodules ----------- - -EpikCord.managers.cache\_manager module ---------------------------------------- - -.. automodule:: EpikCord.managers.cache_manager - :members: - :undoc-members: - :show-inheritance: - -EpikCord.managers.channel\_manager module ------------------------------------------ - -.. automodule:: EpikCord.managers.channel_manager - :members: - :undoc-members: - :show-inheritance: - -EpikCord.managers.guilds\_manager module ----------------------------------------- - -.. automodule:: EpikCord.managers.guilds_manager - :members: - :undoc-members: - :show-inheritance: - -EpikCord.managers.member\_manager module ----------------------------------------- - -.. automodule:: EpikCord.managers.member_manager - :members: - :undoc-members: - :show-inheritance: - -EpikCord.managers.roles\_manager module ---------------------------------------- - -.. automodule:: EpikCord.managers.roles_manager - :members: - :undoc-members: - :show-inheritance: - -Module contents ---------------- - -.. automodule:: EpikCord.managers - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/EpikCord.rst b/docs/EpikCord.rst deleted file mode 100644 index b4903ed2..00000000 --- a/docs/EpikCord.rst +++ /dev/null @@ -1,257 +0,0 @@ -EpikCord package -================ - -Subpackages ------------ - -.. toctree:: - :maxdepth: 4 - - EpikCord.application - EpikCord.client - EpikCord.ext - EpikCord.managers - EpikCord.utils - -Submodules ----------- - -EpikCord.abstract module ------------------------- - -.. automodule:: EpikCord.abstract - :members: - :undoc-members: - :show-inheritance: - -EpikCord.auto\_moderation module --------------------------------- - -.. automodule:: EpikCord.auto_moderation - :members: - :undoc-members: - :show-inheritance: - -EpikCord.channels module ------------------------- - -.. automodule:: EpikCord.channels - :members: - :undoc-members: - :show-inheritance: - -EpikCord.close\_event\_codes module ------------------------------------ - -.. automodule:: EpikCord.close_event_codes - :members: - :undoc-members: - :show-inheritance: - -EpikCord.close\_handler module ------------------------------- - -.. automodule:: EpikCord.close_handler - :members: - :undoc-members: - :show-inheritance: - -EpikCord.colour module ----------------------- - -.. automodule:: EpikCord.colour - :members: - :undoc-members: - :show-inheritance: - -EpikCord.commands module ------------------------- - -.. automodule:: EpikCord.commands - :members: - :undoc-members: - :show-inheritance: - -EpikCord.components module --------------------------- - -.. automodule:: EpikCord.components - :members: - :undoc-members: - :show-inheritance: - -EpikCord.exceptions module --------------------------- - -.. automodule:: EpikCord.exceptions - :members: - :undoc-members: - :show-inheritance: - -EpikCord.flags module ---------------------- - -.. automodule:: EpikCord.flags - :members: - :undoc-members: - :show-inheritance: - -EpikCord.guild module ---------------------- - -.. automodule:: EpikCord.guild - :members: - :undoc-members: - :show-inheritance: - -EpikCord.interactions module ----------------------------- - -.. automodule:: EpikCord.interactions - :members: - :undoc-members: - :show-inheritance: - -EpikCord.localizations module ------------------------------ - -.. automodule:: EpikCord.localizations - :members: - :undoc-members: - :show-inheritance: - -EpikCord.mentioned module -------------------------- - -.. automodule:: EpikCord.mentioned - :members: - :undoc-members: - :show-inheritance: - -EpikCord.message module ------------------------ - -.. automodule:: EpikCord.message - :members: - :undoc-members: - :show-inheritance: - -EpikCord.opcodes module ------------------------ - -.. automodule:: EpikCord.opcodes - :members: - :undoc-members: - :show-inheritance: - -EpikCord.options module ------------------------ - -.. automodule:: EpikCord.options - :members: - :undoc-members: - :show-inheritance: - -EpikCord.partials module ------------------------- - -.. automodule:: EpikCord.partials - :members: - :undoc-members: - :show-inheritance: - -EpikCord.presence module ------------------------- - -.. automodule:: EpikCord.presence - :members: - :undoc-members: - :show-inheritance: - -EpikCord.rtp\_handler module ----------------------------- - -.. automodule:: EpikCord.rtp_handler - :members: - :undoc-members: - :show-inheritance: - -EpikCord.sharding module ------------------------- - -.. automodule:: EpikCord.sharding - :members: - :undoc-members: - :show-inheritance: - -EpikCord.status\_code module ----------------------------- - -.. automodule:: EpikCord.status_code - :members: - :undoc-members: - :show-inheritance: - -EpikCord.sticker module ------------------------ - -.. automodule:: EpikCord.sticker - :members: - :undoc-members: - :show-inheritance: - -EpikCord.thread module ----------------------- - -.. automodule:: EpikCord.thread - :members: - :undoc-members: - :show-inheritance: - -EpikCord.type\_enums module ---------------------------- - -.. automodule:: EpikCord.type_enums - :members: - :undoc-members: - :show-inheritance: - -EpikCord.user module --------------------- - -.. automodule:: EpikCord.user - :members: - :undoc-members: - :show-inheritance: - -EpikCord.voice module ---------------------- - -.. automodule:: EpikCord.voice - :members: - :undoc-members: - :show-inheritance: - -EpikCord.webhooks module ------------------------- - -.. automodule:: EpikCord.webhooks - :members: - :undoc-members: - :show-inheritance: - -EpikCord.ws\_events module --------------------------- - -.. automodule:: EpikCord.ws_events - :members: - :undoc-members: - :show-inheritance: - -Module contents ---------------- - -.. automodule:: EpikCord - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/EpikCord.utils.rst b/docs/EpikCord.utils.rst deleted file mode 100644 index 3bd33ae6..00000000 --- a/docs/EpikCord.utils.rst +++ /dev/null @@ -1,37 +0,0 @@ -EpikCord.utils package -====================== - -Submodules ----------- - -EpikCord.utils.command\_utils module ------------------------------------- - -.. automodule:: EpikCord.utils.command_utils - :members: - :undoc-members: - :show-inheritance: - -EpikCord.utils.paginator module -------------------------------- - -.. automodule:: EpikCord.utils.paginator - :members: - :undoc-members: - :show-inheritance: - -EpikCord.utils.utils module ---------------------------- - -.. automodule:: EpikCord.utils.utils - :members: - :undoc-members: - :show-inheritance: - -Module contents ---------------- - -.. automodule:: EpikCord.utils - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index d4bb2cbb..00000000 --- a/docs/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line, and also -# from the environment for the first two. -SPHINXOPTS ?= -SPHINXBUILD ?= sphinx-build -SOURCEDIR = . -BUILDDIR = _build - -# Put it first so that "make" without argument is like "make help". -help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -.PHONY: help Makefile - -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index 216cdc90..00000000 --- a/docs/conf.py +++ /dev/null @@ -1,54 +0,0 @@ -# Configuration file for the Sphinx documentation builder. -# -# This file only contains a selection of the most common options. For a full -# list see the documentation: -# https://www.sphinx-doc.org/en/master/usage/configuration.html - -# -- Path setup -------------------------------------------------------------- - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# -import os -import sys - -sys.path.insert(0, os.path.abspath("..")) - - -# -- Project information ----------------------------------------------------- - -project = "EpikCord.py" -copyright = "2022, EpikCord" -author = "EpikCord" - - -# -- General configuration --------------------------------------------------- - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [ - "sphinx.ext.autodoc", -] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ["_templates"] - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This pattern also affects html_static_path and html_extra_path. -exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] - - -# -- Options for HTML output ------------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = "alabaster" - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ["_static"] diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index 68d367d9..00000000 --- a/docs/index.rst +++ /dev/null @@ -1,22 +0,0 @@ -.. EpikCord.py documentation master file, created by - sphinx-quickstart on Fri Nov 4 13:10:03 2022. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -Welcome to EpikCord.py's documentation! -======================================= - -.. toctree:: - :maxdepth: 2 - :caption: Contents: - - modules - - - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index 954237b9..00000000 --- a/docs/make.bat +++ /dev/null @@ -1,35 +0,0 @@ -@ECHO OFF - -pushd %~dp0 - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set SOURCEDIR=. -set BUILDDIR=_build - -%SPHINXBUILD% >NUL 2>NUL -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.https://www.sphinx-doc.org/ - exit /b 1 -) - -if "%1" == "" goto help - -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% -goto end - -:help -%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% - -:end -popd diff --git a/docs/modules.rst b/docs/modules.rst deleted file mode 100644 index 628cfa27..00000000 --- a/docs/modules.rst +++ /dev/null @@ -1,7 +0,0 @@ -EpikCord -======== - -.. toctree:: - :maxdepth: 4 - - EpikCord diff --git a/examples/basic_bot.py b/examples/basic_bot.py deleted file mode 100644 index 31ee51c2..00000000 --- a/examples/basic_bot.py +++ /dev/null @@ -1,13 +0,0 @@ -from EpikCord import Client, Intents - -intents = Intents.all() - -client = Client("token", intents) - - -@client.event() -async def ready(): - print("Ready!") - - -client.login() diff --git a/examples/event.py b/examples/event.py deleted file mode 100644 index ae324043..00000000 --- a/examples/event.py +++ /dev/null @@ -1,31 +0,0 @@ -# Registering Events - -from EpikCord import Client, Intents, Messageable - -intents = Intents.all() - -client = Client("your_token", intents) - -# this is one way of registering events -@client.event() -async def message_create(message): - # message action here - if message.author.id == client.user.id: - return - if message.content == "example test": - message.channel = Messageable(client, message.channel_id) - - await message.channel.send(content="Did i hear Example test?") - - -# or -# if you want another way of making events - - -@client.event("guild_member_leave") # Any event name -async def sad_to_see_you_go(guild_id, user): - channel = Messageable(client, 2333232) # a message channel id - await channel.send(content="Saaaaaaaaad to see u go ;(") - - -client.login() diff --git a/examples/message.py b/examples/message.py deleted file mode 100644 index c528d738..00000000 --- a/examples/message.py +++ /dev/null @@ -1,24 +0,0 @@ -""" -Before you implement this in your bot, please note that it's just for testing, -If you have a test bot and are professional with your code, you can experiment -with different features and report the bugs in an issue -""" - -from EpikCord import Client, Intents, Messageable - -intents = Intents.all() - -client = Client("your_token", intents) - - -@client.event() -async def message_create(message): - if message.author.id == client.user.id: - return - if message.content == "example test": - message.channel = Messageable(client, message.channel_id) - - await message.channel.send(content="hello, chat testing") - - -client.login() diff --git a/examples/message_commands.py b/examples/message_commands.py deleted file mode 100644 index 54ff63c5..00000000 --- a/examples/message_commands.py +++ /dev/null @@ -1,18 +0,0 @@ -from EpikCord import Client, Intents - -intents = Intents.all() - -client = Client("token", intents) - - -@client.event() -async def ready(): - print("Ready!") - - -@client.message_command("test") -async def test(interaction): - await interaction.reply(content="Seems like a message to me.") - - -client.login() diff --git a/examples/slash_commands.py b/examples/slash_commands.py deleted file mode 100644 index b95694a6..00000000 --- a/examples/slash_commands.py +++ /dev/null @@ -1,53 +0,0 @@ -import asyncio -import datetime -import time -import humanize - -from EpikCord import Client, Intents, StringOption, NumberOption - -intents = Intents.all() - -client = Client("token", intents) - - -@client.event() -async def ready(): - print("Ready!") - - -@client.command(name="ping", description="A test command", guild_ids=["id"]) -async def ping(interaction): - start = time.perf_counter() - await interaction.reply(content="Pong!") - end = time.perf_counter() - - await asyncio.sleep(0.5) - - trip = end - start - rt_ping = f"{(trip * 1000):.2f}ms ({humanize.precisedelta(datetime.timedelta(seconds=trip))})" - - await interaction.edit_original_response(content=f"Pong! {rt_ping}") - - -@client.command( - name="say", - description="Say what you say", - guild_ids=["id"], - options=[ - StringOption(name="content", description="The message content"), - NumberOption(name="delete_after", description="Delete after"), - ], -) -async def say(interaction, content, delete_after): - await interaction.reply(content=f"{content}") - await asyncio.sleep(int(delete_after)) - await interaction.delete_original_response() - - -# extra -# @client.event -# async def interaction_create(interaction): -# if interaction.is_message_component(): -# await interaction.reply(content = "You clicked me. Why.") - -client.login() diff --git a/examples/user_commands.py b/examples/user_commands.py deleted file mode 100644 index 62333639..00000000 --- a/examples/user_commands.py +++ /dev/null @@ -1,18 +0,0 @@ -from EpikCord import Client, Intents - -intents: Intents = Intents.all() - -client: Client = Client("token", intents) - - -@client.event() -async def ready() -> None: - print("Ready!") - - -@client.user_command("mention") -async def mention(interaction): - await interaction.reply(content="Lol I'm not pinging them.") - - -client.login() From cb644b87a9730b541db09024e2bcdbdb060b358c Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Wed, 21 Dec 2022 12:11:23 +0600 Subject: [PATCH 002/335] Add required file --- EpikCord/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 EpikCord/__init__.py diff --git a/EpikCord/__init__.py b/EpikCord/__init__.py new file mode 100644 index 00000000..e69de29b From ac5b5d80b3a8b4cd3044bea931431331eb1cf646 Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Wed, 21 Dec 2022 12:11:41 +0600 Subject: [PATCH 003/335] Add py.typed --- EpikCord/py.typed | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 EpikCord/py.typed diff --git a/EpikCord/py.typed b/EpikCord/py.typed new file mode 100644 index 00000000..e69de29b From 5d798468515ed9022aa486397b5b026a0a9efcde Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Wed, 21 Dec 2022 12:12:46 +0600 Subject: [PATCH 004/335] Add client folder --- EpikCord/__init__.py | 1 + EpikCord/client/__init__.py | 0 2 files changed, 1 insertion(+) create mode 100644 EpikCord/client/__init__.py diff --git a/EpikCord/__init__.py b/EpikCord/__init__.py index e69de29b..4c51a321 100644 --- a/EpikCord/__init__.py +++ b/EpikCord/__init__.py @@ -0,0 +1 @@ +from .client import * \ No newline at end of file diff --git a/EpikCord/client/__init__.py b/EpikCord/client/__init__.py new file mode 100644 index 00000000..e69de29b From e62f9a37ac9b7bfc4538fcecbaf98b30db36ef60 Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Wed, 21 Dec 2022 12:13:24 +0600 Subject: [PATCH 005/335] Create http_client.py --- EpikCord/client/__init__.py | 1 + EpikCord/client/http_client.py | 0 2 files changed, 1 insertion(+) create mode 100644 EpikCord/client/http_client.py diff --git a/EpikCord/client/__init__.py b/EpikCord/client/__init__.py index e69de29b..ee1f1d58 100644 --- a/EpikCord/client/__init__.py +++ b/EpikCord/client/__init__.py @@ -0,0 +1 @@ +from .http_client import * \ No newline at end of file diff --git a/EpikCord/client/http_client.py b/EpikCord/client/http_client.py new file mode 100644 index 00000000..e69de29b From 92f6232fa4133c2ab784bb21cf0988f5a9fecb74 Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Wed, 21 Dec 2022 12:19:35 +0600 Subject: [PATCH 006/335] Add tasks.py again --- EpikCord/ext/__init__.py | 1 + EpikCord/ext/tasks.py | 41 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 EpikCord/ext/__init__.py create mode 100644 EpikCord/ext/tasks.py diff --git a/EpikCord/ext/__init__.py b/EpikCord/ext/__init__.py new file mode 100644 index 00000000..168d80e2 --- /dev/null +++ b/EpikCord/ext/__init__.py @@ -0,0 +1 @@ +from .tasks import * \ No newline at end of file diff --git a/EpikCord/ext/tasks.py b/EpikCord/ext/tasks.py new file mode 100644 index 00000000..8f2df504 --- /dev/null +++ b/EpikCord/ext/tasks.py @@ -0,0 +1,41 @@ +import asyncio +import typing + +# time constants in seconds +MINUTE = 60 +HOUR = MINUTE * 60 +DAY = HOUR * 24 +WEEK = DAY * 7 + + +class Task: + def __init__(self, wrapped_func, duration: int, max_runs: int): + self.wrapped_func = wrapped_func + self.duration = duration + self.max_runs = max_runs + self.runs = 0 + + async def start(self, *args: typing.Any, **kwargs: typing.Any): + while self.runs < self.max_runs: + await self.wrapped_func(*args, **kwargs) + + if self.max_runs > 0: + self.runs += 1 + + await asyncio.sleep(self.duration) + + def run(self, *args: typing.Any, **kwargs: typing.Any): + return asyncio.create_task(self.start(*args, **kwargs)) + + +def task(seconds=0, minutes=0, hours=0, days=0, weeks=0, max_runs=-1): + duration = seconds + duration += minutes * MINUTE + duration += hours * HOUR + duration += days * DAY + duration += weeks * WEEK + + def wrap(function): + return Task(function, duration, max_runs) + + return wrap \ No newline at end of file From 90031677cdda4633d5f27a2b0f659b31e6347970 Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Thu, 22 Dec 2022 02:27:11 +0600 Subject: [PATCH 007/335] Add flags --- EpikCord/__init__.py | 3 +- EpikCord/client/__init__.py | 1 + EpikCord/client/http_client.py | 193 +++++++++++++++++++++++++++++++++ EpikCord/flags.py | 131 ++++++++++++++++++++++ 4 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 EpikCord/flags.py diff --git a/EpikCord/__init__.py b/EpikCord/__init__.py index 4c51a321..91872eb7 100644 --- a/EpikCord/__init__.py +++ b/EpikCord/__init__.py @@ -1 +1,2 @@ -from .client import * \ No newline at end of file +from .client import * +from .flags import * \ No newline at end of file diff --git a/EpikCord/client/__init__.py b/EpikCord/client/__init__.py index ee1f1d58..abd98c4a 100644 --- a/EpikCord/client/__init__.py +++ b/EpikCord/client/__init__.py @@ -1 +1,2 @@ +from .client import * from .http_client import * \ No newline at end of file diff --git a/EpikCord/client/http_client.py b/EpikCord/client/http_client.py index e69de29b..4497bb16 100644 --- a/EpikCord/client/http_client.py +++ b/EpikCord/client/http_client.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +import asyncio +from typing import Optional, Dict + +import aiohttp + +class MockBucket: + def __init__(self): + self.internal_hash: str = "" + self.external_hash: str = "" + + async def wait(self): + ... + + def clear(self): + ... + + def set(self): + ... + + def __eq__(self, other: MockBucket): + if isinstance(other, MockBucket): + return True + return False + +class Bucket: + def __init__(self, *, bucket_hash: str): + """ + Parameters + ---------- + bucket_hash: str + The bucket hash of the bucket provided by Discord. + + Attributes + ---------- + bucket_hash: str + The bucket hash of the bucket provided by Discord. + event: asyncio.Event + The event that is used to wait for the bucket to be set. + """ + self.hash: str = bucket_hash + self.event: asyncio.Event = asyncio.Event() + self.event.set() + + async def wait(self): + """ + Waits for the bucket to be set. + """ + await self.event.wait() + + def set(self): + """ + Sets the bucket. + """ + self.event.set() + + def clear(self): + """ + Clears the bucket. + """ + self.event.clear() + + + def __eq__(self, other: Bucket): + if isinstance(other, Bucket): + return self.hash == other.hash + return False + +class TopLevelBucket(Bucket): + def __init__(self, *, major_parameters: Dict[str, Any]): + """ + Parameters + ---------- + internal_hash: str + The internal hash of the bucket. + external_hash: str + The external hash of the bucket. + major_parameters: Dict[str, Any] + The major parameters for this Bucket + + Attributes + ---------- + internal_hash: str + The internal hash of the bucket. + external_hash: str + The external hash of the bucket. + major_parameters: Dict[str, Any] + The major parameters for this Bucket + """ + super().__init__(internal_hash=internal_hash, external_hash=external_hash) + self.major_parameters: Dict[str, Any] = major_parameters + + def __eq__(self, other: TopLevelBucket): + if isinstance(other, TopLevelBucket): + return self.major_parameters == other.major_parameters + return False + +class HTTPClient: + def __init__(self, token: str, *, version: int = 10): + self.token: str = token + self.version: int = version + self.session: aiohttp.ClientSession = aiohttp.ClientSession(headers={ + "Authorization": f"Bot {self.token}" + }) + self.buckets: Dict[str, Bucket] = {} + self.global_event: asyncio.Event = asyncio.Event() + self.global_event.set() + + async def ws_connect(self, url: str) -> aiohttp.ClientWebSocketResponse: + return await self.session.ws_connect(url) + + async def request(self, method: str, url: str, *args, discord: bool = True, channel_id: int = 0, guild_id: int = 0, webhook_id: int = 0, webhook_token: str = "None", **kwargs) -> Optional[aiohttp.ClientResponse]: + """ + Parameters + ---------- + method : str + The method of the request. + url : str + The url of the request. + *args + The args to pass to the ClientSession.request method. + discord : bool + Whether this request is to Discord. + channel_id : int + The channel id of the request. + guild_id : int + The guild id of the request. + webhook_id : int + The webhook id of the request. + webhook_token : str + The webhook token of the request. + **kwargs + The kwargs to pass to the ClientSession.request method. + + Returns + ------- + Optional[aiohttp.ClientResponse] + The response of the request. Can be None if the request fails. + + Raises + ------ + NotFound + If the request returns a 404 status code. + Forbidden + If the request returns a 403 status code. + Unauthorized + If the request returns a 401 status code. + BadRequest + If the request returns a 400 status code. + HTTPException + If the request returns a status code that is not OK and not any of the other exceptions. + """ + if not discord: + return await self.session.request(method, url, *args, **kwargs) + if url.startswith("/"): + url = f"https://discord.com/api/v{self.version}{url}" + else: + url = f"https://discord.com/api/v{self.version}/{url}" + + if url.endswith("/"): + url = url[:-1] + + bucket = self.buckets.get(url) or MockBucket() + + await self.global_event.wait() + await bucket.wait() + + async with self.session.request(method, url, *args, **kwargs) as response: + if isinstance(bucket, MockBucket) and response.headers.get("X-RateLimit-Bucket"): + if channel_id or guild_id or webhook_id or webhook_token: + + if response.status == 429: + data = await response.json() + if data["global"]: + self.global_event.clear() + bucket.clear() + await asyncio.sleep(data["retry_after"]) + self.global_event.set() + bucket.set() + return await self.request(method, url, *args, **kwargs) + + elif response.status == 404: + raise NotFound(response) + elif response.status == 403: + raise Forbidden(response) + elif response.status == 401: + raise Unauthorized(response) + elif response.status == 400: + raise BadRequest(response) + elif response.status != 200: + raise HTTPException(response) + return response diff --git a/EpikCord/flags.py b/EpikCord/flags.py new file mode 100644 index 00000000..204cfc65 --- /dev/null +++ b/EpikCord/flags.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from typing import Any, Dict, List + + +class Flag: + class_flags: Dict[str, int] + + def __init_subclass__(cls) -> None: + cls.class_flags = {k: v for k, v in cls.__dict__.items() if isinstance(v, int)} + + def __init__(self, value: int = 0, **kwargs): + self.value = value + self.turned_on: List[str] = [k for k, a in kwargs.items() if a] + + for k, v in self.class_flags.items(): + if v & value and k not in self.turned_on: + self.turned_on.append(k) + + self.calculate_from_turned() + + def calculate_from_turned(self): + value = 0 + for key, flag in self.class_flags.items(): + if key in self.class_flags: + value |= flag + self.value = value + + def __getattribute__(self, __name: str) -> Any: + original = super().__getattribute__ + if __name in original("class_flags"): + return __name in original("turned_on") + return original(__name) + + def __setattr__(self, __name: str, __value: Any) -> None: + if __name not in self.class_flags: + return super().__setattr__(__name, __value) + if __value and __name not in self.turned_on: + self.turned_on.append(__name) + elif not __value and __name in self.turned_on: + self.turned_on.remove(__name) + self.calculate_from_turned() + + @classmethod + def all(cls): + return cls(**{k: True for k in cls.class_flags}) + + +class Intents(Flag): + guilds = 1 << 0 + members = 1 << 1 + bans = 1 << 2 + emojis_and_stickers = 1 << 3 + integrations = 1 << 4 + webhooks = 1 << 5 + invites = 1 << 6 + voice_states = 1 << 7 + presences = 1 << 8 + + guild_messages = 1 << 9 + guild_message_reactions = 1 << 10 + guild_message_typing = 1 << 11 + + direct_messages = 1 << 12 + direct_message_reactions = 1 << 13 + direct_message_typing = 1 << 14 + + message_content = 1 << 15 + scheduled_event = 1 << 16 + + auto_moderation_configuration = 1 << 20 + auto_moderation_action_execution = 1 << 21 + + +class SystemChannelFlags(Flag): + + suppress_join_notifications = 1 << 0 + suppress_premium_subscriptions = 1 << 1 + suppress_guild_reminder_notifications = 1 << 2 + suppress_join_notification_replies = 1 << 3 + + +class Permissions(Flag): + create_instant_invite = 1 << 0 + kick_members = 1 << 1 + ban_members = 1 << 2 + administrator = 1 << 3 + manage_channels = 1 << 4 + manage_guild = 1 << 5 + add_reactions = 1 << 6 + view_audit_log = 1 << 7 + priority_speaker = 1 << 8 + stream = 1 << 9 + read_messages = 1 << 10 + send_messages = 1 << 11 + send_tts_messages = 1 << 12 + manage_messages = 1 << 13 + embed_links = 1 << 14 + attach_files = 1 << 15 + read_message_history = 1 << 16 + mention_everyone = 1 << 17 + use_external_emojis = 1 << 18 + connect = 1 << 20 + speak = 1 << 21 + mute_members = 1 << 22 + deafen_members = 1 << 23 + move_members = 1 << 24 + use_voice_activation = 1 << 25 + change_nickname = 1 << 26 + manage_nicknames = 1 << 27 + manage_roles = 1 << 28 + manage_webhooks = 1 << 29 + manage_emojis_and_stickers = 1 << 30 + use_application_commands = 1 << 31 + request_to_speak = 1 << 32 + manage_events = 1 << 33 + manage_threads = 1 << 34 + create_public_threads = 1 << 35 + create_private_threads = 1 << 36 + use_external_stickers = 1 << 37 + send_messages_in_threads = 1 << 38 + start_embedded_activities = 1 << 39 + moderator_members = 1 << 40 + + +class ChannelFlags(Flag): + pinned = 1 << 1 + require_tag = 1 << 4 + + +__all__ = ("Intents", "SystemChannelFlags", "Permissions", "Flag", "ChannelFlags") \ No newline at end of file From f8b1a5003c585dcf664826decd2e2337d4b4ff76 Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Thu, 22 Dec 2022 02:27:30 +0600 Subject: [PATCH 008/335] Add utils folder --- EpikCord/utils/__init__.py | 1 + EpikCord/utils/loose.py | 8 ++++++++ 2 files changed, 9 insertions(+) create mode 100644 EpikCord/utils/__init__.py create mode 100644 EpikCord/utils/loose.py diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py new file mode 100644 index 00000000..2309f787 --- /dev/null +++ b/EpikCord/utils/__init__.py @@ -0,0 +1 @@ +from .loose import * \ No newline at end of file diff --git a/EpikCord/utils/loose.py b/EpikCord/utils/loose.py new file mode 100644 index 00000000..e9b2c13a --- /dev/null +++ b/EpikCord/utils/loose.py @@ -0,0 +1,8 @@ +def clear_none_values(d: dict): + """ + Clears all the values in a dictionary that are None. + """ + for key in list(d.keys()): + if d[key] is None: + del d[key] + return d \ No newline at end of file From 7d492f0e4e4b2ebfd613451db0ad4a601c27d610 Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Thu, 22 Dec 2022 02:34:54 +0600 Subject: [PATCH 009/335] Add exceptions file --- EpikCord/client/http_client.py | 23 +++++++++++++++++++++-- EpikCord/exceptions.py | 0 2 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 EpikCord/exceptions.py diff --git a/EpikCord/client/http_client.py b/EpikCord/client/http_client.py index 4497bb16..b1e4afcc 100644 --- a/EpikCord/client/http_client.py +++ b/EpikCord/client/http_client.py @@ -1,5 +1,6 @@ from __future__ import annotations +from ..utils import clear_none_values import asyncio from typing import Optional, Dict @@ -110,7 +111,7 @@ def __init__(self, token: str, *, version: int = 10): async def ws_connect(self, url: str) -> aiohttp.ClientWebSocketResponse: return await self.session.ws_connect(url) - async def request(self, method: str, url: str, *args, discord: bool = True, channel_id: int = 0, guild_id: int = 0, webhook_id: int = 0, webhook_token: str = "None", **kwargs) -> Optional[aiohttp.ClientResponse]: + async def request(self, method: str, url: str, *args, discord: bool = True, channel_id: Optional[int] = None, guild_id: Optional[int] = None, webhook_id: Optional[int] = None, webhook_token: Optional[str] = None, **kwargs) -> Optional[aiohttp.ClientResponse]: """ Parameters ---------- @@ -151,8 +152,10 @@ async def request(self, method: str, url: str, *args, discord: bool = True, cha HTTPException If the request returns a status code that is not OK and not any of the other exceptions. """ + if not discord: return await self.session.request(method, url, *args, **kwargs) + if url.startswith("/"): url = f"https://discord.com/api/v{self.version}{url}" else: @@ -161,7 +164,7 @@ async def request(self, method: str, url: str, *args, discord: bool = True, cha if url.endswith("/"): url = url[:-1] - bucket = self.buckets.get(url) or MockBucket() + bucket = self.buckets.get(f"{method}:{url}") or MockBucket() await self.global_event.wait() await bucket.wait() @@ -169,6 +172,22 @@ async def request(self, method: str, url: str, *args, discord: bool = True, cha async with self.session.request(method, url, *args, **kwargs) as response: if isinstance(bucket, MockBucket) and response.headers.get("X-RateLimit-Bucket"): if channel_id or guild_id or webhook_id or webhook_token: + bucket = TopLevelBucket( + major_parameters=clear_none_values({ + "channel_id": channel_id, + "guild_id": guild_id, + "webhook_id": webhook_id, + "webhook_token": webhook_token + }) + ) + self.buckets[f"{method}:{url}"] = bucket + else: + bucket = Bucket(bucket_hash=response.headers["X-RateLimit-Bucket"]) + if bucket in self.buckets.values(): + self.buckets[f"{method}:{url}"] = list(self.buckets.values())[list(self.buckets.values()).index(bucket)] + bucket = self.buckets[f"{method}:{url}"] + else: + self.buckets[f"{method}:{url}"] = bucket if response.status == 429: data = await response.json() diff --git a/EpikCord/exceptions.py b/EpikCord/exceptions.py new file mode 100644 index 00000000..e69de29b From 415f9fa31d4526f9cc70063957c15db4eb2c077d Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Thu, 22 Dec 2022 02:35:59 +0600 Subject: [PATCH 010/335] Add exception names --- EpikCord/client/http_client.py | 1 + EpikCord/exceptions.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/EpikCord/client/http_client.py b/EpikCord/client/http_client.py index b1e4afcc..4dccbfda 100644 --- a/EpikCord/client/http_client.py +++ b/EpikCord/client/http_client.py @@ -1,5 +1,6 @@ from __future__ import annotations +from ..exceptions import from ..utils import clear_none_values import asyncio from typing import Optional, Dict diff --git a/EpikCord/exceptions.py b/EpikCord/exceptions.py index e69de29b..b903031a 100644 --- a/EpikCord/exceptions.py +++ b/EpikCord/exceptions.py @@ -0,0 +1,14 @@ +class NotFound: + ... + +class Forbidden: + ... + +class Unauthorized: + ... + +class BadRequest: + ... + +class HTTPException: + ... \ No newline at end of file From bb84055b72030e53fda21ed1732ea54d7781dbb5 Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Thu, 22 Dec 2022 02:36:18 +0600 Subject: [PATCH 011/335] Import exceptions --- EpikCord/client/http_client.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/EpikCord/client/http_client.py b/EpikCord/client/http_client.py index 4dccbfda..2513151c 100644 --- a/EpikCord/client/http_client.py +++ b/EpikCord/client/http_client.py @@ -1,6 +1,12 @@ from __future__ import annotations -from ..exceptions import +from ..exceptions import ( + NotFound, + Forbidden, + Unauthorized, + BadRequest, + HTTPException +) from ..utils import clear_none_values import asyncio from typing import Optional, Dict From 86939b878d488b08f947096a2c0b02c3d607b4ed Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Thu, 22 Dec 2022 02:36:53 +0600 Subject: [PATCH 012/335] Add client --- EpikCord/client/client.py | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 EpikCord/client/client.py diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py new file mode 100644 index 00000000..dad605e6 --- /dev/null +++ b/EpikCord/client/client.py @@ -0,0 +1,7 @@ +from ..flags import Intents + +class Client: + def __init__(self, token: str, intents: Intents): + self.token: str = token + self.intents: Intents = intents.value + \ No newline at end of file From 1e0d23ab3778506a2235058b8cbb619f8c4a6e20 Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Thu, 22 Dec 2022 02:37:10 +0600 Subject: [PATCH 013/335] Format with black --- EpikCord/__init__.py | 2 +- EpikCord/client/__init__.py | 2 +- EpikCord/client/client.py | 2 +- EpikCord/client/http_client.py | 60 +++++++++++++++++++++------------- EpikCord/exceptions.py | 6 +++- EpikCord/ext/__init__.py | 2 +- EpikCord/ext/tasks.py | 2 +- EpikCord/flags.py | 2 +- EpikCord/utils/__init__.py | 2 +- EpikCord/utils/loose.py | 2 +- 10 files changed, 50 insertions(+), 32 deletions(-) diff --git a/EpikCord/__init__.py b/EpikCord/__init__.py index 91872eb7..4568ceec 100644 --- a/EpikCord/__init__.py +++ b/EpikCord/__init__.py @@ -1,2 +1,2 @@ from .client import * -from .flags import * \ No newline at end of file +from .flags import * diff --git a/EpikCord/client/__init__.py b/EpikCord/client/__init__.py index abd98c4a..90e91587 100644 --- a/EpikCord/client/__init__.py +++ b/EpikCord/client/__init__.py @@ -1,2 +1,2 @@ from .client import * -from .http_client import * \ No newline at end of file +from .http_client import * diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index dad605e6..60297e7d 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -1,7 +1,7 @@ from ..flags import Intents + class Client: def __init__(self, token: str, intents: Intents): self.token: str = token self.intents: Intents = intents.value - \ No newline at end of file diff --git a/EpikCord/client/http_client.py b/EpikCord/client/http_client.py index 2513151c..3dc0c131 100644 --- a/EpikCord/client/http_client.py +++ b/EpikCord/client/http_client.py @@ -1,18 +1,13 @@ from __future__ import annotations -from ..exceptions import ( - NotFound, - Forbidden, - Unauthorized, - BadRequest, - HTTPException -) +from ..exceptions import NotFound, Forbidden, Unauthorized, BadRequest, HTTPException from ..utils import clear_none_values import asyncio from typing import Optional, Dict import aiohttp + class MockBucket: def __init__(self): self.internal_hash: str = "" @@ -32,6 +27,7 @@ def __eq__(self, other: MockBucket): return True return False + class Bucket: def __init__(self, *, bucket_hash: str): """ @@ -69,12 +65,12 @@ def clear(self): """ self.event.clear() - def __eq__(self, other: Bucket): if isinstance(other, Bucket): return self.hash == other.hash return False + class TopLevelBucket(Bucket): def __init__(self, *, major_parameters: Dict[str, Any]): """ @@ -104,13 +100,14 @@ def __eq__(self, other: TopLevelBucket): return self.major_parameters == other.major_parameters return False + class HTTPClient: def __init__(self, token: str, *, version: int = 10): self.token: str = token self.version: int = version - self.session: aiohttp.ClientSession = aiohttp.ClientSession(headers={ - "Authorization": f"Bot {self.token}" - }) + self.session: aiohttp.ClientSession = aiohttp.ClientSession( + headers={"Authorization": f"Bot {self.token}"} + ) self.buckets: Dict[str, Bucket] = {} self.global_event: asyncio.Event = asyncio.Event() self.global_event.set() @@ -118,7 +115,18 @@ def __init__(self, token: str, *, version: int = 10): async def ws_connect(self, url: str) -> aiohttp.ClientWebSocketResponse: return await self.session.ws_connect(url) - async def request(self, method: str, url: str, *args, discord: bool = True, channel_id: Optional[int] = None, guild_id: Optional[int] = None, webhook_id: Optional[int] = None, webhook_token: Optional[str] = None, **kwargs) -> Optional[aiohttp.ClientResponse]: + async def request( + self, + method: str, + url: str, + *args, + discord: bool = True, + channel_id: Optional[int] = None, + guild_id: Optional[int] = None, + webhook_id: Optional[int] = None, + webhook_token: Optional[str] = None, + **kwargs, + ) -> Optional[aiohttp.ClientResponse]: """ Parameters ---------- @@ -145,7 +153,7 @@ async def request(self, method: str, url: str, *args, discord: bool = True, cha ------- Optional[aiohttp.ClientResponse] The response of the request. Can be None if the request fails. - + Raises ------ NotFound @@ -162,7 +170,7 @@ async def request(self, method: str, url: str, *args, discord: bool = True, cha if not discord: return await self.session.request(method, url, *args, **kwargs) - + if url.startswith("/"): url = f"https://discord.com/api/v{self.version}{url}" else: @@ -170,28 +178,34 @@ async def request(self, method: str, url: str, *args, discord: bool = True, cha if url.endswith("/"): url = url[:-1] - + bucket = self.buckets.get(f"{method}:{url}") or MockBucket() await self.global_event.wait() await bucket.wait() async with self.session.request(method, url, *args, **kwargs) as response: - if isinstance(bucket, MockBucket) and response.headers.get("X-RateLimit-Bucket"): + if isinstance(bucket, MockBucket) and response.headers.get( + "X-RateLimit-Bucket" + ): if channel_id or guild_id or webhook_id or webhook_token: bucket = TopLevelBucket( - major_parameters=clear_none_values({ - "channel_id": channel_id, - "guild_id": guild_id, - "webhook_id": webhook_id, - "webhook_token": webhook_token - }) + major_parameters=clear_none_values( + { + "channel_id": channel_id, + "guild_id": guild_id, + "webhook_id": webhook_id, + "webhook_token": webhook_token, + } + ) ) self.buckets[f"{method}:{url}"] = bucket else: bucket = Bucket(bucket_hash=response.headers["X-RateLimit-Bucket"]) if bucket in self.buckets.values(): - self.buckets[f"{method}:{url}"] = list(self.buckets.values())[list(self.buckets.values()).index(bucket)] + self.buckets[f"{method}:{url}"] = list(self.buckets.values())[ + list(self.buckets.values()).index(bucket) + ] bucket = self.buckets[f"{method}:{url}"] else: self.buckets[f"{method}:{url}"] = bucket diff --git a/EpikCord/exceptions.py b/EpikCord/exceptions.py index b903031a..ea1d51aa 100644 --- a/EpikCord/exceptions.py +++ b/EpikCord/exceptions.py @@ -1,14 +1,18 @@ class NotFound: ... + class Forbidden: ... + class Unauthorized: ... + class BadRequest: ... + class HTTPException: - ... \ No newline at end of file + ... diff --git a/EpikCord/ext/__init__.py b/EpikCord/ext/__init__.py index 168d80e2..f5456c32 100644 --- a/EpikCord/ext/__init__.py +++ b/EpikCord/ext/__init__.py @@ -1 +1 @@ -from .tasks import * \ No newline at end of file +from .tasks import * diff --git a/EpikCord/ext/tasks.py b/EpikCord/ext/tasks.py index 8f2df504..67f1258b 100644 --- a/EpikCord/ext/tasks.py +++ b/EpikCord/ext/tasks.py @@ -38,4 +38,4 @@ def task(seconds=0, minutes=0, hours=0, days=0, weeks=0, max_runs=-1): def wrap(function): return Task(function, duration, max_runs) - return wrap \ No newline at end of file + return wrap diff --git a/EpikCord/flags.py b/EpikCord/flags.py index 204cfc65..74b0ec6a 100644 --- a/EpikCord/flags.py +++ b/EpikCord/flags.py @@ -128,4 +128,4 @@ class ChannelFlags(Flag): require_tag = 1 << 4 -__all__ = ("Intents", "SystemChannelFlags", "Permissions", "Flag", "ChannelFlags") \ No newline at end of file +__all__ = ("Intents", "SystemChannelFlags", "Permissions", "Flag", "ChannelFlags") diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py index 2309f787..e2b28ea2 100644 --- a/EpikCord/utils/__init__.py +++ b/EpikCord/utils/__init__.py @@ -1 +1 @@ -from .loose import * \ No newline at end of file +from .loose import * diff --git a/EpikCord/utils/loose.py b/EpikCord/utils/loose.py index e9b2c13a..c9db52c4 100644 --- a/EpikCord/utils/loose.py +++ b/EpikCord/utils/loose.py @@ -5,4 +5,4 @@ def clear_none_values(d: dict): for key in list(d.keys()): if d[key] is None: del d[key] - return d \ No newline at end of file + return d From 445248e434e630dc60b3858c9753d2bb5f31ce12 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sat, 24 Dec 2022 11:43:15 +0600 Subject: [PATCH 014/335] Remove redundant if checks Co-authored-by: Emre Terzioglu <50607143+EmreTech@users.noreply.github.com> --- EpikCord/client/http_client.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/EpikCord/client/http_client.py b/EpikCord/client/http_client.py index 3dc0c131..5e59843c 100644 --- a/EpikCord/client/http_client.py +++ b/EpikCord/client/http_client.py @@ -23,9 +23,7 @@ def set(self): ... def __eq__(self, other: MockBucket): - if isinstance(other, MockBucket): - return True - return False + return isinstance(other, MockBucket): class Bucket: From 5c31be76aabe905cd6aa8a347e8f2f6159003fc7 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sat, 24 Dec 2022 11:43:35 +0600 Subject: [PATCH 015/335] Make typehints in docstring look better Co-authored-by: Emre Terzioglu <50607143+EmreTech@users.noreply.github.com> --- EpikCord/client/http_client.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/EpikCord/client/http_client.py b/EpikCord/client/http_client.py index 5e59843c..0463ab3f 100644 --- a/EpikCord/client/http_client.py +++ b/EpikCord/client/http_client.py @@ -128,21 +128,21 @@ async def request( """ Parameters ---------- - method : str + method: str The method of the request. - url : str + url: str The url of the request. *args The args to pass to the ClientSession.request method. - discord : bool + discord: bool Whether this request is to Discord. - channel_id : int + channel_id: int The channel id of the request. - guild_id : int + guild_id: int The guild id of the request. - webhook_id : int + webhook_id: int The webhook id of the request. - webhook_token : str + webhook_token: str The webhook token of the request. **kwargs The kwargs to pass to the ClientSession.request method. From 3c23941123feec16be6c95bae9d5254a683f46fa Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Sat, 24 Dec 2022 16:34:44 +0600 Subject: [PATCH 016/335] Remove _client suffix to http --- EpikCord/client/http.py | 233 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 EpikCord/client/http.py diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py new file mode 100644 index 00000000..3dc0c131 --- /dev/null +++ b/EpikCord/client/http.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +from ..exceptions import NotFound, Forbidden, Unauthorized, BadRequest, HTTPException +from ..utils import clear_none_values +import asyncio +from typing import Optional, Dict + +import aiohttp + + +class MockBucket: + def __init__(self): + self.internal_hash: str = "" + self.external_hash: str = "" + + async def wait(self): + ... + + def clear(self): + ... + + def set(self): + ... + + def __eq__(self, other: MockBucket): + if isinstance(other, MockBucket): + return True + return False + + +class Bucket: + def __init__(self, *, bucket_hash: str): + """ + Parameters + ---------- + bucket_hash: str + The bucket hash of the bucket provided by Discord. + + Attributes + ---------- + bucket_hash: str + The bucket hash of the bucket provided by Discord. + event: asyncio.Event + The event that is used to wait for the bucket to be set. + """ + self.hash: str = bucket_hash + self.event: asyncio.Event = asyncio.Event() + self.event.set() + + async def wait(self): + """ + Waits for the bucket to be set. + """ + await self.event.wait() + + def set(self): + """ + Sets the bucket. + """ + self.event.set() + + def clear(self): + """ + Clears the bucket. + """ + self.event.clear() + + def __eq__(self, other: Bucket): + if isinstance(other, Bucket): + return self.hash == other.hash + return False + + +class TopLevelBucket(Bucket): + def __init__(self, *, major_parameters: Dict[str, Any]): + """ + Parameters + ---------- + internal_hash: str + The internal hash of the bucket. + external_hash: str + The external hash of the bucket. + major_parameters: Dict[str, Any] + The major parameters for this Bucket + + Attributes + ---------- + internal_hash: str + The internal hash of the bucket. + external_hash: str + The external hash of the bucket. + major_parameters: Dict[str, Any] + The major parameters for this Bucket + """ + super().__init__(internal_hash=internal_hash, external_hash=external_hash) + self.major_parameters: Dict[str, Any] = major_parameters + + def __eq__(self, other: TopLevelBucket): + if isinstance(other, TopLevelBucket): + return self.major_parameters == other.major_parameters + return False + + +class HTTPClient: + def __init__(self, token: str, *, version: int = 10): + self.token: str = token + self.version: int = version + self.session: aiohttp.ClientSession = aiohttp.ClientSession( + headers={"Authorization": f"Bot {self.token}"} + ) + self.buckets: Dict[str, Bucket] = {} + self.global_event: asyncio.Event = asyncio.Event() + self.global_event.set() + + async def ws_connect(self, url: str) -> aiohttp.ClientWebSocketResponse: + return await self.session.ws_connect(url) + + async def request( + self, + method: str, + url: str, + *args, + discord: bool = True, + channel_id: Optional[int] = None, + guild_id: Optional[int] = None, + webhook_id: Optional[int] = None, + webhook_token: Optional[str] = None, + **kwargs, + ) -> Optional[aiohttp.ClientResponse]: + """ + Parameters + ---------- + method : str + The method of the request. + url : str + The url of the request. + *args + The args to pass to the ClientSession.request method. + discord : bool + Whether this request is to Discord. + channel_id : int + The channel id of the request. + guild_id : int + The guild id of the request. + webhook_id : int + The webhook id of the request. + webhook_token : str + The webhook token of the request. + **kwargs + The kwargs to pass to the ClientSession.request method. + + Returns + ------- + Optional[aiohttp.ClientResponse] + The response of the request. Can be None if the request fails. + + Raises + ------ + NotFound + If the request returns a 404 status code. + Forbidden + If the request returns a 403 status code. + Unauthorized + If the request returns a 401 status code. + BadRequest + If the request returns a 400 status code. + HTTPException + If the request returns a status code that is not OK and not any of the other exceptions. + """ + + if not discord: + return await self.session.request(method, url, *args, **kwargs) + + if url.startswith("/"): + url = f"https://discord.com/api/v{self.version}{url}" + else: + url = f"https://discord.com/api/v{self.version}/{url}" + + if url.endswith("/"): + url = url[:-1] + + bucket = self.buckets.get(f"{method}:{url}") or MockBucket() + + await self.global_event.wait() + await bucket.wait() + + async with self.session.request(method, url, *args, **kwargs) as response: + if isinstance(bucket, MockBucket) and response.headers.get( + "X-RateLimit-Bucket" + ): + if channel_id or guild_id or webhook_id or webhook_token: + bucket = TopLevelBucket( + major_parameters=clear_none_values( + { + "channel_id": channel_id, + "guild_id": guild_id, + "webhook_id": webhook_id, + "webhook_token": webhook_token, + } + ) + ) + self.buckets[f"{method}:{url}"] = bucket + else: + bucket = Bucket(bucket_hash=response.headers["X-RateLimit-Bucket"]) + if bucket in self.buckets.values(): + self.buckets[f"{method}:{url}"] = list(self.buckets.values())[ + list(self.buckets.values()).index(bucket) + ] + bucket = self.buckets[f"{method}:{url}"] + else: + self.buckets[f"{method}:{url}"] = bucket + + if response.status == 429: + data = await response.json() + if data["global"]: + self.global_event.clear() + bucket.clear() + await asyncio.sleep(data["retry_after"]) + self.global_event.set() + bucket.set() + return await self.request(method, url, *args, **kwargs) + + elif response.status == 404: + raise NotFound(response) + elif response.status == 403: + raise Forbidden(response) + elif response.status == 401: + raise Unauthorized(response) + elif response.status == 400: + raise BadRequest(response) + elif response.status != 200: + raise HTTPException(response) + return response From a190434a4d16625f31044f14a72729237158f646 Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Sat, 24 Dec 2022 16:34:52 +0600 Subject: [PATCH 017/335] Remove _client suffix to http --- EpikCord/client/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EpikCord/client/__init__.py b/EpikCord/client/__init__.py index 90e91587..df063a3d 100644 --- a/EpikCord/client/__init__.py +++ b/EpikCord/client/__init__.py @@ -1,2 +1,2 @@ from .client import * -from .http_client import * +from .http import * From caca83a0f43860e02f3ed49f6f999bfb4d13b1b5 Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Sat, 24 Dec 2022 16:40:34 +0600 Subject: [PATCH 018/335] Fix bad check --- EpikCord/client/http.py | 20 +-- EpikCord/client/http_client.py | 233 --------------------------------- 2 files changed, 4 insertions(+), 249 deletions(-) delete mode 100644 EpikCord/client/http_client.py diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 3dc0c131..1c8582c9 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -3,15 +3,12 @@ from ..exceptions import NotFound, Forbidden, Unauthorized, BadRequest, HTTPException from ..utils import clear_none_values import asyncio -from typing import Optional, Dict +from typing import Optional, Dict, Any import aiohttp class MockBucket: - def __init__(self): - self.internal_hash: str = "" - self.external_hash: str = "" async def wait(self): ... @@ -71,29 +68,20 @@ def __eq__(self, other: Bucket): return False -class TopLevelBucket(Bucket): +class TopLevelBucket: def __init__(self, *, major_parameters: Dict[str, Any]): """ Parameters ---------- - internal_hash: str - The internal hash of the bucket. - external_hash: str - The external hash of the bucket. major_parameters: Dict[str, Any] The major parameters for this Bucket Attributes ---------- - internal_hash: str - The internal hash of the bucket. - external_hash: str - The external hash of the bucket. major_parameters: Dict[str, Any] The major parameters for this Bucket """ - super().__init__(internal_hash=internal_hash, external_hash=external_hash) - self.major_parameters: Dict[str, Any] = major_parameters + self.major_parameters: Dict[str, Any] = major_parameters def __eq__(self, other: TopLevelBucket): if isinstance(other, TopLevelBucket): @@ -228,6 +216,6 @@ async def request( raise Unauthorized(response) elif response.status == 400: raise BadRequest(response) - elif response.status != 200: + elif not response.ok: raise HTTPException(response) return response diff --git a/EpikCord/client/http_client.py b/EpikCord/client/http_client.py deleted file mode 100644 index 3dc0c131..00000000 --- a/EpikCord/client/http_client.py +++ /dev/null @@ -1,233 +0,0 @@ -from __future__ import annotations - -from ..exceptions import NotFound, Forbidden, Unauthorized, BadRequest, HTTPException -from ..utils import clear_none_values -import asyncio -from typing import Optional, Dict - -import aiohttp - - -class MockBucket: - def __init__(self): - self.internal_hash: str = "" - self.external_hash: str = "" - - async def wait(self): - ... - - def clear(self): - ... - - def set(self): - ... - - def __eq__(self, other: MockBucket): - if isinstance(other, MockBucket): - return True - return False - - -class Bucket: - def __init__(self, *, bucket_hash: str): - """ - Parameters - ---------- - bucket_hash: str - The bucket hash of the bucket provided by Discord. - - Attributes - ---------- - bucket_hash: str - The bucket hash of the bucket provided by Discord. - event: asyncio.Event - The event that is used to wait for the bucket to be set. - """ - self.hash: str = bucket_hash - self.event: asyncio.Event = asyncio.Event() - self.event.set() - - async def wait(self): - """ - Waits for the bucket to be set. - """ - await self.event.wait() - - def set(self): - """ - Sets the bucket. - """ - self.event.set() - - def clear(self): - """ - Clears the bucket. - """ - self.event.clear() - - def __eq__(self, other: Bucket): - if isinstance(other, Bucket): - return self.hash == other.hash - return False - - -class TopLevelBucket(Bucket): - def __init__(self, *, major_parameters: Dict[str, Any]): - """ - Parameters - ---------- - internal_hash: str - The internal hash of the bucket. - external_hash: str - The external hash of the bucket. - major_parameters: Dict[str, Any] - The major parameters for this Bucket - - Attributes - ---------- - internal_hash: str - The internal hash of the bucket. - external_hash: str - The external hash of the bucket. - major_parameters: Dict[str, Any] - The major parameters for this Bucket - """ - super().__init__(internal_hash=internal_hash, external_hash=external_hash) - self.major_parameters: Dict[str, Any] = major_parameters - - def __eq__(self, other: TopLevelBucket): - if isinstance(other, TopLevelBucket): - return self.major_parameters == other.major_parameters - return False - - -class HTTPClient: - def __init__(self, token: str, *, version: int = 10): - self.token: str = token - self.version: int = version - self.session: aiohttp.ClientSession = aiohttp.ClientSession( - headers={"Authorization": f"Bot {self.token}"} - ) - self.buckets: Dict[str, Bucket] = {} - self.global_event: asyncio.Event = asyncio.Event() - self.global_event.set() - - async def ws_connect(self, url: str) -> aiohttp.ClientWebSocketResponse: - return await self.session.ws_connect(url) - - async def request( - self, - method: str, - url: str, - *args, - discord: bool = True, - channel_id: Optional[int] = None, - guild_id: Optional[int] = None, - webhook_id: Optional[int] = None, - webhook_token: Optional[str] = None, - **kwargs, - ) -> Optional[aiohttp.ClientResponse]: - """ - Parameters - ---------- - method : str - The method of the request. - url : str - The url of the request. - *args - The args to pass to the ClientSession.request method. - discord : bool - Whether this request is to Discord. - channel_id : int - The channel id of the request. - guild_id : int - The guild id of the request. - webhook_id : int - The webhook id of the request. - webhook_token : str - The webhook token of the request. - **kwargs - The kwargs to pass to the ClientSession.request method. - - Returns - ------- - Optional[aiohttp.ClientResponse] - The response of the request. Can be None if the request fails. - - Raises - ------ - NotFound - If the request returns a 404 status code. - Forbidden - If the request returns a 403 status code. - Unauthorized - If the request returns a 401 status code. - BadRequest - If the request returns a 400 status code. - HTTPException - If the request returns a status code that is not OK and not any of the other exceptions. - """ - - if not discord: - return await self.session.request(method, url, *args, **kwargs) - - if url.startswith("/"): - url = f"https://discord.com/api/v{self.version}{url}" - else: - url = f"https://discord.com/api/v{self.version}/{url}" - - if url.endswith("/"): - url = url[:-1] - - bucket = self.buckets.get(f"{method}:{url}") or MockBucket() - - await self.global_event.wait() - await bucket.wait() - - async with self.session.request(method, url, *args, **kwargs) as response: - if isinstance(bucket, MockBucket) and response.headers.get( - "X-RateLimit-Bucket" - ): - if channel_id or guild_id or webhook_id or webhook_token: - bucket = TopLevelBucket( - major_parameters=clear_none_values( - { - "channel_id": channel_id, - "guild_id": guild_id, - "webhook_id": webhook_id, - "webhook_token": webhook_token, - } - ) - ) - self.buckets[f"{method}:{url}"] = bucket - else: - bucket = Bucket(bucket_hash=response.headers["X-RateLimit-Bucket"]) - if bucket in self.buckets.values(): - self.buckets[f"{method}:{url}"] = list(self.buckets.values())[ - list(self.buckets.values()).index(bucket) - ] - bucket = self.buckets[f"{method}:{url}"] - else: - self.buckets[f"{method}:{url}"] = bucket - - if response.status == 429: - data = await response.json() - if data["global"]: - self.global_event.clear() - bucket.clear() - await asyncio.sleep(data["retry_after"]) - self.global_event.set() - bucket.set() - return await self.request(method, url, *args, **kwargs) - - elif response.status == 404: - raise NotFound(response) - elif response.status == 403: - raise Forbidden(response) - elif response.status == 401: - raise Unauthorized(response) - elif response.status == 400: - raise BadRequest(response) - elif response.status != 200: - raise HTTPException(response) - return response From 9aebd1a40d6ae07b82d6d0a82d648d516c4335c2 Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Sat, 24 Dec 2022 17:12:12 +0600 Subject: [PATCH 019/335] Lessen weight on HTTPClient.request --- EpikCord/client/http.py | 137 +++++++++++++++++++++++++--------------- EpikCord/exceptions.py | 19 ++++-- 2 files changed, 100 insertions(+), 56 deletions(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 1c8582c9..10bed876 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -3,7 +3,7 @@ from ..exceptions import NotFound, Forbidden, Unauthorized, BadRequest, HTTPException from ..utils import clear_none_values import asyncio -from typing import Optional, Dict, Any +from typing import Optional, Dict, Any, Union, Type import aiohttp @@ -81,7 +81,27 @@ def __init__(self, *, major_parameters: Dict[str, Any]): major_parameters: Dict[str, Any] The major parameters for this Bucket """ - self.major_parameters: Dict[str, Any] = major_parameters + self.event = asyncio.Event() + self.event.set() + self.major_parameters: Dict[str, Any] = major_parameters + + async def wait(self): + """ + Waits for the bucket to be set. + """ + await self.event.wait() + + def set(self): + """ + Sets the bucket. + """ + self.event.set() + + def clear(self): + """ + Clears the bucket. + """ + self.event.clear() def __eq__(self, other: TopLevelBucket): if isinstance(other, TopLevelBucket): @@ -90,13 +110,19 @@ def __eq__(self, other: TopLevelBucket): class HTTPClient: + error_mapping: Dict[int, Union[Type[NotFound], Type[Forbidden], Type[Unauthorized], Type[BadRequest]]] = { + 400: BadRequest, + 401: Unauthorized, + 403: Forbidden, + 404: NotFound + } def __init__(self, token: str, *, version: int = 10): self.token: str = token self.version: int = version self.session: aiohttp.ClientSession = aiohttp.ClientSession( headers={"Authorization": f"Bot {self.token}"} ) - self.buckets: Dict[str, Bucket] = {} + self.buckets: Dict[str, Union[Bucket, TopLevelBucket]] = {} self.global_event: asyncio.Event = asyncio.Event() self.global_event.set() @@ -171,51 +197,62 @@ async def request( await self.global_event.wait() await bucket.wait() + for _ in range(5): + async with self.session.request(method, url, *args, **kwargs) as response: + if isinstance(bucket, MockBucket): + bucket = await self.set_bucket(response=response, channel_id=channel_id, guild_id=guild_id, webhook_id=webhook_id, webhook_token=webhook_token) + + data = await self.extract_content(response) + + if response.status == 429: + await self.handle_ratelimit(data, bucket) + elif not response.ok: + raise self.error_mapping[response.status](response, data) + + return response + + async def set_bucket(self, *, response: aiohttp.ClientResponse, channel_id: Optional[int] = None, guild_id: Optional[int] = None, webhook_id: Optional[int] = None, webhook_token: Optional[str] = None) -> Union[Bucket, TopLevelBucket]: + url = response.url + method = response.method + + if channel_id or guild_id or webhook_id or webhook_token: + bucket = TopLevelBucket( + major_parameters=clear_none_values( + { + "channel_id": channel_id, + "guild_id": guild_id, + "webhook_id": webhook_id, + "webhook_token": webhook_token, + } + ) + ) + + else: + + bucket = Bucket(bucket_hash=response.headers["X-RateLimit-Bucket"]) + if bucket in self.buckets.values(): + self.buckets[f"{method}:{url}"] = list(self.buckets.values())[ + list(self.buckets.values()).index(bucket) + ] + bucket = self.buckets[f"{method}:{url}"] + self.buckets[f"{method}:{url}"] = bucket - async with self.session.request(method, url, *args, **kwargs) as response: - if isinstance(bucket, MockBucket) and response.headers.get( - "X-RateLimit-Bucket" - ): - if channel_id or guild_id or webhook_id or webhook_token: - bucket = TopLevelBucket( - major_parameters=clear_none_values( - { - "channel_id": channel_id, - "guild_id": guild_id, - "webhook_id": webhook_id, - "webhook_token": webhook_token, - } - ) - ) - self.buckets[f"{method}:{url}"] = bucket - else: - bucket = Bucket(bucket_hash=response.headers["X-RateLimit-Bucket"]) - if bucket in self.buckets.values(): - self.buckets[f"{method}:{url}"] = list(self.buckets.values())[ - list(self.buckets.values()).index(bucket) - ] - bucket = self.buckets[f"{method}:{url}"] - else: - self.buckets[f"{method}:{url}"] = bucket - - if response.status == 429: - data = await response.json() - if data["global"]: - self.global_event.clear() - bucket.clear() - await asyncio.sleep(data["retry_after"]) - self.global_event.set() - bucket.set() - return await self.request(method, url, *args, **kwargs) - - elif response.status == 404: - raise NotFound(response) - elif response.status == 403: - raise Forbidden(response) - elif response.status == 401: - raise Unauthorized(response) - elif response.status == 400: - raise BadRequest(response) - elif not response.ok: - raise HTTPException(response) - return response + return bucket + + async def extract_content(self, response: aiohttp.ClientResponse) -> Dict[str, Any]: + if response.headers["Content-Type"] == "application/json": + data = await response.json() + else: + data = {} + return data + + async def handle_ratelimit(self, data: Dict[str, Any], bucket: Union[Bucket, TopLevelBucket]): + bucket.clear() + + if data["global"]: + self.global_event.clear() + + await asyncio.sleep(data["retry_after"]) + + self.global_event.set() + bucket.set() \ No newline at end of file diff --git a/EpikCord/exceptions.py b/EpikCord/exceptions.py index ea1d51aa..d30cff06 100644 --- a/EpikCord/exceptions.py +++ b/EpikCord/exceptions.py @@ -1,18 +1,25 @@ -class NotFound: - ... +from typing import Dict +import aiohttp -class Forbidden: +class EpikCordException(Exception): ... +class HTTPException(EpikCordException): + def __init__(self, response: aiohttp.ClientResponse, data: Dict): + ... -class Unauthorized: +class NotFound(HTTPException): ... -class BadRequest: +class Forbidden(HTTPException): ... -class HTTPException: +class Unauthorized(HTTPException): ... + + +class BadRequest(HTTPException): + ... \ No newline at end of file From e544c7156c5150f4a1fe51019e801d51ee1355c5 Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Sat, 24 Dec 2022 17:21:52 +0600 Subject: [PATCH 020/335] Add util function to clean url --- EpikCord/client/http.py | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 8fbd889d..9083d821 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -20,7 +20,7 @@ def set(self): ... def __eq__(self, other: MockBucket): - return isinstance(other, MockBucket): + return isinstance(other, MockBucket) class Bucket: @@ -183,18 +183,12 @@ async def request( if not discord: return await self.session.request(method, url, *args, **kwargs) - if url.startswith("/"): - url = f"https://discord.com/api/v{self.version}{url}" - else: - url = f"https://discord.com/api/v{self.version}/{url}" - - if url.endswith("/"): - url = url[:-1] - + url = self.clean_url(url) bucket = self.buckets.get(f"{method}:{url}") or MockBucket() await self.global_event.wait() await bucket.wait() + for _ in range(5): async with self.session.request(method, url, *args, **kwargs) as response: if isinstance(bucket, MockBucket): @@ -253,4 +247,15 @@ async def handle_ratelimit(self, data: Dict[str, Any], bucket: Union[Bucket, Top await asyncio.sleep(data["retry_after"]) self.global_event.set() - bucket.set() \ No newline at end of file + bucket.set() + + def clean_url(self, url: str) -> str: + if url.startswith("/"): + url = f"https://discord.com/api/v{self.version}{url}" + else: + url = f"https://discord.com/api/v{self.version}/{url}" + + if url.endswith("/"): + url = url[:-1] + + return url \ No newline at end of file From fde7662e90ddb4effef20afcc908dedc59acff77 Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Sat, 24 Dec 2022 17:22:34 +0600 Subject: [PATCH 021/335] Minimise clear_none_values function --- EpikCord/utils/loose.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/EpikCord/utils/loose.py b/EpikCord/utils/loose.py index c9db52c4..ce93ae5e 100644 --- a/EpikCord/utils/loose.py +++ b/EpikCord/utils/loose.py @@ -2,7 +2,4 @@ def clear_none_values(d: dict): """ Clears all the values in a dictionary that are None. """ - for key in list(d.keys()): - if d[key] is None: - del d[key] - return d + return {k: v for k, v in d.items() if v is not None} \ No newline at end of file From a61d588d10ba21076d6f026e77677a36ea6e3a34 Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Sat, 24 Dec 2022 17:41:48 +0600 Subject: [PATCH 022/335] Minor changes --- EpikCord/__init__.py | 2 + EpikCord/client/http.py | 96 ++++++++++++++++++++++++++++++++++------- EpikCord/exceptions.py | 5 ++- EpikCord/utils/loose.py | 2 +- 4 files changed, 87 insertions(+), 18 deletions(-) diff --git a/EpikCord/__init__.py b/EpikCord/__init__.py index 4568ceec..2363966e 100644 --- a/EpikCord/__init__.py +++ b/EpikCord/__init__.py @@ -1,2 +1,4 @@ +__version__ = "0.6.0" + from .client import * from .flags import * diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 9083d821..3d21fa33 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -1,15 +1,20 @@ from __future__ import annotations +from .. import __version__ from ..exceptions import NotFound, Forbidden, Unauthorized, BadRequest, HTTPException from ..utils import clear_none_values + + import asyncio +from logging import getLogger from typing import Optional, Dict, Any, Union, Type import aiohttp +logger = getLogger("EpikCord.http") -class MockBucket: +class MockBucket: async def wait(self): ... @@ -20,7 +25,7 @@ def set(self): ... def __eq__(self, other: MockBucket): - return isinstance(other, MockBucket) + return isinstance(other, MockBucket) class Bucket: @@ -46,18 +51,22 @@ async def wait(self): """ Waits for the bucket to be set. """ + logger.info(f"Waiting for bucket {self.hash} to be set.") await self.event.wait() + logger.info(f"Done waiting for bucket {self.hash}.") def set(self): """ Sets the bucket. """ + logger.info(f"Setting bucket {self.hash}.") self.event.set() def clear(self): """ Clears the bucket. """ + logger.info(f"Clearing bucket {self.hash}.") self.event.clear() def __eq__(self, other: Bucket): @@ -87,18 +96,22 @@ async def wait(self): """ Waits for the bucket to be set. """ + logger.info(f"Waiting for bucket {self.major_parameters} to be set.") await self.event.wait() + logger.info(f"Done waiting for bucket {self.major_parameters}.") def set(self): """ Sets the bucket. """ + logger.info(f"Setting bucket {self.major_parameters}.") self.event.set() - + def clear(self): """ Clears the bucket. """ + logger.info(f"Clearing bucket {self.major_parameters}.") self.event.clear() def __eq__(self, other: TopLevelBucket): @@ -106,26 +119,38 @@ def __eq__(self, other: TopLevelBucket): return self.major_parameters == other.major_parameters return False + def __str__(self): + return f"channel_id={self.major_parameters.get('channel_id')}, guild_id={self.major_parameters.get('guild_id')}, webhook_id={self.major_parameters.get('webhook_id')}, webhook_token={self.major_parameters.get('webhook_token')}" + class HTTPClient: - error_mapping: Dict[int, Union[Type[NotFound], Type[Forbidden], Type[Unauthorized], Type[BadRequest]]] = { - 400: BadRequest, - 401: Unauthorized, - 403: Forbidden, - 404: NotFound - } + error_mapping: Dict[ + int, + Union[Type[NotFound], Type[Forbidden], Type[Unauthorized], Type[BadRequest]], + ] = {400: BadRequest, 401: Unauthorized, 403: Forbidden, 404: NotFound} + def __init__(self, token: str, *, version: int = 10): self.token: str = token self.version: int = version self.session: aiohttp.ClientSession = aiohttp.ClientSession( - headers={"Authorization": f"Bot {self.token}"} + headers={ + "Authorization": f"Bot {self.token}", + "User-Agent": f"DiscordBot (https://github.com/EpikCord/EpikCord.py {__version__})", + } ) self.buckets: Dict[str, Union[Bucket, TopLevelBucket]] = {} self.global_event: asyncio.Event = asyncio.Event() self.global_event.set() - async def ws_connect(self, url: str) -> aiohttp.ClientWebSocketResponse: - return await self.session.ws_connect(url) + async def ws_connect( + self, url: str, headers: Dict = {}, **kwargs + ) -> aiohttp.ClientWebSocketResponse: + headers.update( + { + "User-Agent": f"DiscordBot (https://github.com/EpikCord/EpikCord.py {__version__})" + } + ) + return await self.session.ws_connect(url, headers=headers, **kwargs) async def request( self, @@ -192,7 +217,13 @@ async def request( for _ in range(5): async with self.session.request(method, url, *args, **kwargs) as response: if isinstance(bucket, MockBucket): - bucket = await self.set_bucket(response=response, channel_id=channel_id, guild_id=guild_id, webhook_id=webhook_id, webhook_token=webhook_token) + bucket = await self.set_bucket( + response=response, + channel_id=channel_id, + guild_id=guild_id, + webhook_id=webhook_id, + webhook_token=webhook_token, + ) data = await self.extract_content(response) @@ -203,7 +234,15 @@ async def request( return response - async def set_bucket(self, *, response: aiohttp.ClientResponse, channel_id: Optional[int] = None, guild_id: Optional[int] = None, webhook_id: Optional[int] = None, webhook_token: Optional[str] = None) -> Union[Bucket, TopLevelBucket]: + async def set_bucket( + self, + *, + response: aiohttp.ClientResponse, + channel_id: Optional[int] = None, + guild_id: Optional[int] = None, + webhook_id: Optional[int] = None, + webhook_token: Optional[str] = None, + ) -> Union[Bucket, TopLevelBucket]: url = response.url method = response.method @@ -238,7 +277,9 @@ async def extract_content(self, response: aiohttp.ClientResponse) -> Dict[str, A data = {} return data - async def handle_ratelimit(self, data: Dict[str, Any], bucket: Union[Bucket, TopLevelBucket]): + async def handle_ratelimit( + self, data: Dict[str, Any], bucket: Union[Bucket, TopLevelBucket] + ): bucket.clear() if data["global"]: @@ -258,4 +299,27 @@ def clean_url(self, url: str) -> str: if url.endswith("/"): url = url[:-1] - return url \ No newline at end of file + return url + + @staticmethod + async def log_request(res, body: Optional[dict] = None): + messages = [ + f"Sent a {res.method} to {res.url} " + f"and got a {res.status} response. ", + f"Content-Type: {res.headers['Content-Type']} ", + ] + + if body: + messages.append(f"Sent body: {body} ") + + if h := dict(res.request_info.headers): + messages.append(f"Sent headers: {h} ") + + if h := dict(res.headers): + messages.append(f"Received headers: {h} ") + + try: + messages.append(f"Received body: {await res.json()} ") + + finally: + logger.debug("".join(messages)) diff --git a/EpikCord/exceptions.py b/EpikCord/exceptions.py index d30cff06..087af17d 100644 --- a/EpikCord/exceptions.py +++ b/EpikCord/exceptions.py @@ -2,13 +2,16 @@ import aiohttp + class EpikCordException(Exception): ... + class HTTPException(EpikCordException): def __init__(self, response: aiohttp.ClientResponse, data: Dict): ... + class NotFound(HTTPException): ... @@ -22,4 +25,4 @@ class Unauthorized(HTTPException): class BadRequest(HTTPException): - ... \ No newline at end of file + ... diff --git a/EpikCord/utils/loose.py b/EpikCord/utils/loose.py index ce93ae5e..f9a6d887 100644 --- a/EpikCord/utils/loose.py +++ b/EpikCord/utils/loose.py @@ -2,4 +2,4 @@ def clear_none_values(d: dict): """ Clears all the values in a dictionary that are None. """ - return {k: v for k, v in d.items() if v is not None} \ No newline at end of file + return {k: v for k, v in d.items() if v is not None} From ad8c381c3952a10f3b5418adceda4917795333c6 Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Sat, 24 Dec 2022 17:43:52 +0600 Subject: [PATCH 023/335] Add Coroutine type --- EpikCord/utils/types.py | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 EpikCord/utils/types.py diff --git a/EpikCord/utils/types.py b/EpikCord/utils/types.py new file mode 100644 index 00000000..db49df4a --- /dev/null +++ b/EpikCord/utils/types.py @@ -0,0 +1,3 @@ +from typing import Callable, Coroutine as _Coroutine, Any + +Coroutine = Callable[..., _Coroutine[Any, Any, Any]] \ No newline at end of file From 7adecf74d35ef4d2678d1a4a277c9b0776aec0c8 Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Sat, 24 Dec 2022 17:45:02 +0600 Subject: [PATCH 024/335] Use Coroutine type --- EpikCord/ext/tasks.py | 4 +++- EpikCord/utils/__init__.py | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/EpikCord/ext/tasks.py b/EpikCord/ext/tasks.py index 67f1258b..6762784b 100644 --- a/EpikCord/ext/tasks.py +++ b/EpikCord/ext/tasks.py @@ -1,6 +1,8 @@ import asyncio import typing +from ..utils import Coroutine + # time constants in seconds MINUTE = 60 HOUR = MINUTE * 60 @@ -9,7 +11,7 @@ class Task: - def __init__(self, wrapped_func, duration: int, max_runs: int): + def __init__(self, wrapped_func: Coroutine, duration: int, max_runs: int): self.wrapped_func = wrapped_func self.duration = duration self.max_runs = max_runs diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py index e2b28ea2..f5c7e864 100644 --- a/EpikCord/utils/__init__.py +++ b/EpikCord/utils/__init__.py @@ -1 +1,2 @@ from .loose import * +from .types import * \ No newline at end of file From 396d15e3db3ff0f99e82f8e370bd03995f10398f Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Sat, 24 Dec 2022 17:49:20 +0600 Subject: [PATCH 025/335] Use timedelta to simplify ext.tasks --- EpikCord/ext/tasks.py | 29 ++++++++--------------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/EpikCord/ext/tasks.py b/EpikCord/ext/tasks.py index 6762784b..0df52dee 100644 --- a/EpikCord/ext/tasks.py +++ b/EpikCord/ext/tasks.py @@ -1,21 +1,15 @@ import asyncio +from datetime import timedelta import typing from ..utils import Coroutine -# time constants in seconds -MINUTE = 60 -HOUR = MINUTE * 60 -DAY = HOUR * 24 -WEEK = DAY * 7 - - class Task: - def __init__(self, wrapped_func: Coroutine, duration: int, max_runs: int): - self.wrapped_func = wrapped_func - self.duration = duration - self.max_runs = max_runs - self.runs = 0 + def __init__(self, wrapped_func: Coroutine, duration: float, max_runs: int): + self.wrapped_func: Coroutine = wrapped_func + self.duration: float = duration + self.max_runs: int = max_runs + self.runs: int = 0 async def start(self, *args: typing.Any, **kwargs: typing.Any): while self.runs < self.max_runs: @@ -29,15 +23,8 @@ async def start(self, *args: typing.Any, **kwargs: typing.Any): def run(self, *args: typing.Any, **kwargs: typing.Any): return asyncio.create_task(self.start(*args, **kwargs)) - -def task(seconds=0, minutes=0, hours=0, days=0, weeks=0, max_runs=-1): - duration = seconds - duration += minutes * MINUTE - duration += hours * HOUR - duration += days * DAY - duration += weeks * WEEK - +def task(duration: timedelta, max_runs=-1): def wrap(function): - return Task(function, duration, max_runs) + return Task(function, duration.total_seconds(), max_runs) return wrap From c664884d8365c41ebd67aafe13ca18fb6cd394ac Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Sat, 24 Dec 2022 19:49:02 +0600 Subject: [PATCH 026/335] Add support for sending files in requests --- EpikCord/client/http.py | 64 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 4 deletions(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 3d21fa33..85ebf0ab 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -4,15 +4,39 @@ from ..exceptions import NotFound, Forbidden, Unauthorized, BadRequest, HTTPException from ..utils import clear_none_values - import asyncio +import mimetypes +from importlib.util import find_spec +from io import IOBase from logging import getLogger -from typing import Optional, Dict, Any, Union, Type +from typing import Optional, Dict, Any, Union, Type, List + +_ORJSON = find_spec("orjson") + +if _ORJSON: + import orjson as json +else: + import json import aiohttp logger = getLogger("EpikCord.http") +class File: + def __init__(self, *, filename: str, contents: IOBase, mime_type: Optional[str] = None): + """ + Parameters + ---------- + filename: str + The filename of the file. + contents: IOBase + The contents of the file. + mime_type: Optional[str] + The mime type of the file. If not provided, it will be guessed. + """ + self.filename: str = filename + self.contents: IOBase = contents + self.mime_type: Optional[str] = mime_type or mimetypes.guess_type(filename)[0] class MockBucket: async def wait(self): @@ -126,7 +150,7 @@ def __str__(self): class HTTPClient: error_mapping: Dict[ int, - Union[Type[NotFound], Type[Forbidden], Type[Unauthorized], Type[BadRequest]], + Union[Type[NotFound], Type[Forbidden], Type[Unauthorized], Type[BadRequest]] ] = {400: BadRequest, 401: Unauthorized, 403: Forbidden, 404: NotFound} def __init__(self, token: str, *, version: int = 10): @@ -136,7 +160,10 @@ def __init__(self, token: str, *, version: int = 10): headers={ "Authorization": f"Bot {self.token}", "User-Agent": f"DiscordBot (https://github.com/EpikCord/EpikCord.py {__version__})", - } + }, + json_serialize=lambda x, *__, **___: json.dumps(x).decode("utf-8") # type: ignore + if _ORJSON + else json.dumps(x), ) self.buckets: Dict[str, Union[Bucket, TopLevelBucket]] = {} self.global_event: asyncio.Event = asyncio.Event() @@ -162,6 +189,8 @@ async def request( guild_id: Optional[int] = None, webhook_id: Optional[int] = None, webhook_token: Optional[str] = None, + json: Optional[Dict] = None, + files: Optional[List[File]] = None, **kwargs, ) -> Optional[aiohttp.ClientResponse]: """ @@ -183,6 +212,10 @@ async def request( The webhook id of the request. webhook_token: str The webhook token of the request. + json: Optional[Dict] + The json to pass to the ClientSession.request method. + files: Optional[List[File]] + The files to pass to the request. **kwargs The kwargs to pass to the ClientSession.request method. @@ -215,6 +248,22 @@ async def request( await bucket.wait() for _ in range(5): + if json and not files: + kwargs["json"] = json + else: + form = aiohttp.FormData() + if json: + form.add_field("payload_json", self.session.json_serialize(json)) + if files: + for i, file in enumerate(files): + form.add_field( + f"files[{i}]", + file.contents, + filename=file.filename, + content_type=file.mime_type, + ) + kwargs["data"] = form + async with self.session.request(method, url, *args, **kwargs) as response: if isinstance(bucket, MockBucket): bucket = await self.set_bucket( @@ -229,6 +278,8 @@ async def request( if response.status == 429: await self.handle_ratelimit(data, bucket) + elif response.headers.get("X-RateLimit-Remaining", "1") == "0": + await self.handle_exhausted_bucket(response, bucket) elif not response.ok: raise self.error_mapping[response.status](response, data) @@ -277,6 +328,11 @@ async def extract_content(self, response: aiohttp.ClientResponse) -> Dict[str, A data = {} return data + async def handle_exhausted_bucket(self, response: aiohttp.ClientResponse, bucket: Union[Bucket, TopLevelBucket]): + bucket.clear() + await asyncio.sleep(int(response.headers["X-RateLimit-Reset-After"])) + bucket.set() + async def handle_ratelimit( self, data: Dict[str, Any], bucket: Union[Bucket, TopLevelBucket] ): From 4d947abb014e808a06f6c58aa5d9d0def2b36bf2 Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Sat, 24 Dec 2022 19:58:42 +0600 Subject: [PATCH 027/335] Sort imports --- EpikCord/client/http.py | 52 ++++++++++++++++++++++++----------------- EpikCord/ext/tasks.py | 3 ++- EpikCord/utils/types.py | 5 ++-- 3 files changed, 36 insertions(+), 24 deletions(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 85ebf0ab..8adb99a2 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -1,15 +1,17 @@ from __future__ import annotations -from .. import __version__ -from ..exceptions import NotFound, Forbidden, Unauthorized, BadRequest, HTTPException -from ..utils import clear_none_values - import asyncio import mimetypes from importlib.util import find_spec from io import IOBase from logging import getLogger -from typing import Optional, Dict, Any, Union, Type, List +from typing import Any, Dict, List, Optional, Type, Union + +from .. import __version__ +from ..exceptions import (BadRequest, Forbidden, HTTPException, NotFound, + Unauthorized) +from ..utils import clear_none_values +from .websocket import GatewayWebsocket _ORJSON = find_spec("orjson") @@ -164,6 +166,7 @@ def __init__(self, token: str, *, version: int = 10): json_serialize=lambda x, *__, **___: json.dumps(x).decode("utf-8") # type: ignore if _ORJSON else json.dumps(x), + ws_response_class=GatewayWebsocket, ) self.buckets: Dict[str, Union[Bucket, TopLevelBucket]] = {} self.global_event: asyncio.Event = asyncio.Event() @@ -241,6 +244,8 @@ async def request( if not discord: return await self.session.request(method, url, *args, **kwargs) + self.setup_kwargs(kwargs, files=files, json=json) + url = self.clean_url(url) bucket = self.buckets.get(f"{method}:{url}") or MockBucket() @@ -248,21 +253,6 @@ async def request( await bucket.wait() for _ in range(5): - if json and not files: - kwargs["json"] = json - else: - form = aiohttp.FormData() - if json: - form.add_field("payload_json", self.session.json_serialize(json)) - if files: - for i, file in enumerate(files): - form.add_field( - f"files[{i}]", - file.contents, - filename=file.filename, - content_type=file.mime_type, - ) - kwargs["data"] = form async with self.session.request(method, url, *args, **kwargs) as response: if isinstance(bucket, MockBucket): @@ -282,7 +272,6 @@ async def request( await self.handle_exhausted_bucket(response, bucket) elif not response.ok: raise self.error_mapping[response.status](response, data) - return response async def set_bucket( @@ -379,3 +368,24 @@ async def log_request(res, body: Optional[dict] = None): finally: logger.debug("".join(messages)) + + def setup_kwargs(self, kwargs, *, files: Optional[List[File]], json: Optional[Dict]) -> None: + if not json and not files: + return + elif json and not files: + kwargs["json"] = json + else: + form = aiohttp.FormData() + + if json: + form.add_field("payload_json", self.session.json_serialize(json)) + + if files: + for i, file in enumerate(files): + form.add_field( + f"files[{i}]", + file.contents, + filename=file.filename, + content_type=file.mime_type, + ) + kwargs["data"] = form diff --git a/EpikCord/ext/tasks.py b/EpikCord/ext/tasks.py index 0df52dee..9f9381e3 100644 --- a/EpikCord/ext/tasks.py +++ b/EpikCord/ext/tasks.py @@ -1,9 +1,10 @@ import asyncio -from datetime import timedelta import typing +from datetime import timedelta from ..utils import Coroutine + class Task: def __init__(self, wrapped_func: Coroutine, duration: float, max_runs: int): self.wrapped_func: Coroutine = wrapped_func diff --git a/EpikCord/utils/types.py b/EpikCord/utils/types.py index db49df4a..c76cf4a8 100644 --- a/EpikCord/utils/types.py +++ b/EpikCord/utils/types.py @@ -1,3 +1,4 @@ -from typing import Callable, Coroutine as _Coroutine, Any +from typing import Any, Callable +from typing import Coroutine as _Coroutine -Coroutine = Callable[..., _Coroutine[Any, Any, Any]] \ No newline at end of file +Coroutine = Callable[..., _Coroutine[Any, Any, Any]] From d3717d8d5abca5bb25696c3b21248947ef74122e Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Sat, 24 Dec 2022 20:10:51 +0600 Subject: [PATCH 028/335] Add support for attachments and files --- EpikCord/client/http.py | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 8adb99a2..d5924b70 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -25,7 +25,7 @@ logger = getLogger("EpikCord.http") class File: - def __init__(self, *, filename: str, contents: IOBase, mime_type: Optional[str] = None): + def __init__(self, *, filename: str, contents: IOBase, mime_type: Optional[str] = None, spoiler: bool = False, ephemeral: bool = False, description: Optional[str] = None): """ Parameters ---------- @@ -35,10 +35,17 @@ def __init__(self, *, filename: str, contents: IOBase, mime_type: Optional[str] The contents of the file. mime_type: Optional[str] The mime type of the file. If not provided, it will be guessed. + spoiler: bool + Whether or not the file is a spoiler. """ - self.filename: str = filename self.contents: IOBase = contents self.mime_type: Optional[str] = mime_type or mimetypes.guess_type(filename)[0] + if spoiler: + self.filename: str = f"SPOILER_{filename}" + else: + self.filename: str = filename + self.ephemeral: bool = ephemeral + self.description: Optional[str] = description class MockBucket: async def wait(self): @@ -271,7 +278,8 @@ async def request( elif response.headers.get("X-RateLimit-Remaining", "1") == "0": await self.handle_exhausted_bucket(response, bucket) elif not response.ok: - raise self.error_mapping[response.status](response, data) + error = self.error_mapping.get(response.status, HTTPException) + raise error(response, data) return response async def set_bucket( @@ -369,16 +377,16 @@ async def log_request(res, body: Optional[dict] = None): finally: logger.debug("".join(messages)) - def setup_kwargs(self, kwargs, *, files: Optional[List[File]], json: Optional[Dict]) -> None: + def setup_kwargs(self, kwargs, *, files: Optional[List[File]] = None, json: Optional[Dict] = None) -> None: if not json and not files: return elif json and not files: kwargs["json"] = json - else: + elif files: + if not json: + json = {} form = aiohttp.FormData() - - if json: - form.add_field("payload_json", self.session.json_serialize(json)) + json["attachments"] = [] if files: for i, file in enumerate(files): @@ -388,4 +396,16 @@ def setup_kwargs(self, kwargs, *, files: Optional[List[File]], json: Optional[Di filename=file.filename, content_type=file.mime_type, ) + attachment = { + "filename": file.filename, + "id": i, + "ephemeral": file.ephemeral, + } + if file.description: + attachment["description"] = file.description + + json["attachments"].append(attachment) + + form.add_field("payload_json", self.session.json_serialize(json)) + kwargs["data"] = form From bff0dc9f532bbf18eeaec8070a240629266e4a47 Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Sat, 24 Dec 2022 22:11:09 +0600 Subject: [PATCH 029/335] Create Route class for requests --- EpikCord/client/client.py | 2 +- EpikCord/client/http.py | 86 ++++++++++++++++++++++++-------------- EpikCord/ext/tasks.py | 1 + EpikCord/utils/__init__.py | 2 +- 4 files changed, 57 insertions(+), 34 deletions(-) diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index 60297e7d..f83f7c91 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -4,4 +4,4 @@ class Client: def __init__(self, token: str, intents: Intents): self.token: str = token - self.intents: Intents = intents.value + self.intents: Intents = intents diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index d5924b70..8b549237 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -2,14 +2,14 @@ import asyncio import mimetypes +from functools import partialmethod from importlib.util import find_spec from io import IOBase from logging import getLogger from typing import Any, Dict, List, Optional, Type, Union from .. import __version__ -from ..exceptions import (BadRequest, Forbidden, HTTPException, NotFound, - Unauthorized) +from ..exceptions import BadRequest, Forbidden, HTTPException, NotFound, Unauthorized from ..utils import clear_none_values from .websocket import GatewayWebsocket @@ -24,8 +24,43 @@ logger = getLogger("EpikCord.http") + +class Route: + def __init__( + self, + method: str, + url: str, + *, + channel_id: Optional[int] = None, + guild_id: Optional[int] = None, + webhook_id: Optional[int] = None, + webhook_token: Optional[str] = None, + ): + self.method: str = method + self.url: str = url + self.channel_id: Optional[int] = channel_id + self.guild_id: Optional[int] = guild_id + self.webhook_id: Optional[int] = webhook_id + self.webhook_token: Optional[str] = webhook_token + self.major_parameters: Dict[str, Optional[Union[int, str]]] = clear_none_values({ + "channel_id": channel_id, + "guild_id": guild_id, + "webhook_id": webhook_id, + "webhook_token": webhook_token, + }) + + class File: - def __init__(self, *, filename: str, contents: IOBase, mime_type: Optional[str] = None, spoiler: bool = False, ephemeral: bool = False, description: Optional[str] = None): + def __init__( + self, + *, + filename: str, + contents: IOBase, + mime_type: Optional[str] = None, + spoiler: bool = False, + ephemeral: bool = False, + description: Optional[str] = None, + ): """ Parameters ---------- @@ -47,6 +82,7 @@ def __init__(self, *, filename: str, contents: IOBase, mime_type: Optional[str] self.ephemeral: bool = ephemeral self.description: Optional[str] = description + class MockBucket: async def wait(self): ... @@ -159,7 +195,7 @@ def __str__(self): class HTTPClient: error_mapping: Dict[ int, - Union[Type[NotFound], Type[Forbidden], Type[Unauthorized], Type[BadRequest]] + Union[Type[NotFound], Type[Forbidden], Type[Unauthorized], Type[BadRequest]], ] = {400: BadRequest, 401: Unauthorized, 403: Forbidden, 404: NotFound} def __init__(self, token: str, *, version: int = 10): @@ -170,7 +206,7 @@ def __init__(self, token: str, *, version: int = 10): "Authorization": f"Bot {self.token}", "User-Agent": f"DiscordBot (https://github.com/EpikCord/EpikCord.py {__version__})", }, - json_serialize=lambda x, *__, **___: json.dumps(x).decode("utf-8") # type: ignore + json_serialize=lambda x, *__, **___: json.dumps(x).decode("utf-8") # type: ignore if _ORJSON else json.dumps(x), ws_response_class=GatewayWebsocket, @@ -191,14 +227,9 @@ async def ws_connect( async def request( self, - method: str, - url: str, + route: Route, *args, discord: bool = True, - channel_id: Optional[int] = None, - guild_id: Optional[int] = None, - webhook_id: Optional[int] = None, - webhook_token: Optional[str] = None, json: Optional[Dict] = None, files: Optional[List[File]] = None, **kwargs, @@ -206,22 +237,10 @@ async def request( """ Parameters ---------- - method: str - The method of the request. - url: str - The url of the request. *args The args to pass to the ClientSession.request method. discord: bool Whether this request is to Discord. - channel_id: int - The channel id of the request. - guild_id: int - The guild id of the request. - webhook_id: int - The webhook id of the request. - webhook_token: str - The webhook token of the request. json: Optional[Dict] The json to pass to the ClientSession.request method. files: Optional[List[File]] @@ -248,6 +267,9 @@ async def request( If the request returns a status code that is not OK and not any of the other exceptions. """ + method = route.method + url = route.url + if not discord: return await self.session.request(method, url, *args, **kwargs) @@ -265,10 +287,7 @@ async def request( if isinstance(bucket, MockBucket): bucket = await self.set_bucket( response=response, - channel_id=channel_id, - guild_id=guild_id, - webhook_id=webhook_id, - webhook_token=webhook_token, + **route.major_parameters, ) data = await self.extract_content(response) @@ -325,7 +344,9 @@ async def extract_content(self, response: aiohttp.ClientResponse) -> Dict[str, A data = {} return data - async def handle_exhausted_bucket(self, response: aiohttp.ClientResponse, bucket: Union[Bucket, TopLevelBucket]): + async def handle_exhausted_bucket( + self, response: aiohttp.ClientResponse, bucket: Union[Bucket, TopLevelBucket] + ): bucket.clear() await asyncio.sleep(int(response.headers["X-RateLimit-Reset-After"])) bucket.set() @@ -357,8 +378,7 @@ def clean_url(self, url: str) -> str: @staticmethod async def log_request(res, body: Optional[dict] = None): messages = [ - f"Sent a {res.method} to {res.url} " - f"and got a {res.status} response. ", + f"Sent a {res.method} to {res.url} " f"and got a {res.status} response. ", f"Content-Type: {res.headers['Content-Type']} ", ] @@ -377,7 +397,9 @@ async def log_request(res, body: Optional[dict] = None): finally: logger.debug("".join(messages)) - def setup_kwargs(self, kwargs, *, files: Optional[List[File]] = None, json: Optional[Dict] = None) -> None: + def setup_kwargs( + self, kwargs, *, files: Optional[List[File]] = None, json: Optional[Dict] = None + ) -> None: if not json and not files: return elif json and not files: @@ -408,4 +430,4 @@ def setup_kwargs(self, kwargs, *, files: Optional[List[File]] = None, json: Opti form.add_field("payload_json", self.session.json_serialize(json)) - kwargs["data"] = form + kwargs["data"] = form \ No newline at end of file diff --git a/EpikCord/ext/tasks.py b/EpikCord/ext/tasks.py index 9f9381e3..20987bb6 100644 --- a/EpikCord/ext/tasks.py +++ b/EpikCord/ext/tasks.py @@ -24,6 +24,7 @@ async def start(self, *args: typing.Any, **kwargs: typing.Any): def run(self, *args: typing.Any, **kwargs: typing.Any): return asyncio.create_task(self.start(*args, **kwargs)) + def task(duration: timedelta, max_runs=-1): def wrap(function): return Task(function, duration.total_seconds(), max_runs) diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py index f5c7e864..0cabb2a7 100644 --- a/EpikCord/utils/__init__.py +++ b/EpikCord/utils/__init__.py @@ -1,2 +1,2 @@ from .loose import * -from .types import * \ No newline at end of file +from .types import * From d873313a53a1d463501a59dd296ffe0db94b5473 Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Sat, 24 Dec 2022 22:13:11 +0600 Subject: [PATCH 030/335] Add close method to close session --- EpikCord/client/http.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 8b549237..3c7c2dcf 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -430,4 +430,9 @@ def setup_kwargs( form.add_field("payload_json", self.session.json_serialize(json)) - kwargs["data"] = form \ No newline at end of file + kwargs["data"] = form + + async def close(self) -> None: + await self.session.close() + + \ No newline at end of file From 34930d91fd020601e41ca3115ed53a535969ae13 Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Sat, 24 Dec 2022 23:31:32 +0600 Subject: [PATCH 031/335] Drop ephemeral property of File --- EpikCord/client/http.py | 99 ++++++++++++++++++++++++++++------------- 1 file changed, 68 insertions(+), 31 deletions(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 3c7c2dcf..97c0e247 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -2,7 +2,6 @@ import asyncio import mimetypes -from functools import partialmethod from importlib.util import find_spec from io import IOBase from logging import getLogger @@ -42,24 +41,25 @@ def __init__( self.guild_id: Optional[int] = guild_id self.webhook_id: Optional[int] = webhook_id self.webhook_token: Optional[str] = webhook_token - self.major_parameters: Dict[str, Optional[Union[int, str]]] = clear_none_values({ - "channel_id": channel_id, - "guild_id": guild_id, - "webhook_id": webhook_id, - "webhook_token": webhook_token, - }) + self.major_parameters: Dict[str, Optional[Union[int, str]]] = clear_none_values( + { + "channel_id": channel_id, + "guild_id": guild_id, + "webhook_id": webhook_id, + "webhook_token": webhook_token, + } + ) class File: def __init__( self, - *, filename: str, contents: IOBase, + *, + description: Optional[str] = None, mime_type: Optional[str] = None, spoiler: bool = False, - ephemeral: bool = False, - description: Optional[str] = None, ): """ Parameters @@ -72,6 +72,10 @@ def __init__( The mime type of the file. If not provided, it will be guessed. spoiler: bool Whether or not the file is a spoiler. + ephemeral: bool + Whether or not the file is ephemeral. + description: Optional[str] + The description of the file. """ self.contents: IOBase = contents self.mime_type: Optional[str] = mime_type or mimetypes.guess_type(filename)[0] @@ -79,10 +83,8 @@ def __init__( self.filename: str = f"SPOILER_{filename}" else: self.filename: str = filename - self.ephemeral: bool = ephemeral self.description: Optional[str] = description - class MockBucket: async def wait(self): ... @@ -145,21 +147,55 @@ def __eq__(self, other: Bucket): class TopLevelBucket: - def __init__(self, *, major_parameters: Dict[str, Any]): + def __init__( + self, + *, + channel_id: Optional[int] = None, + guild_id: Optional[int] = None, + webhook_id: Optional[int] = None, + webhook_token: Optional[str] = None, + ): """ Parameters ---------- - major_parameters: Dict[str, Any] - The major parameters for this Bucket + channel_id: Optional[int] + The channel ID of the bucket. + guild_id: Optional[int] + The guild ID of the bucket. + webhook_id: Optional[int] = None + The webhook ID of the bucket. + webhook_token: Optional[str] + The webhook token of the bucket. Attributes ---------- + channel_id: Optional[int] + The channel ID of the bucket. + guild_id: Optional[int] + The guild ID of the bucket. + webhook_id: Optional[int] = None + The webhook ID of the bucket. + webhook_token: Optional[str] + The webhook token of the bucket. major_parameters: Dict[str, Any] The major parameters for this Bucket + event: asyncio.Event + The event that is used to wait for the bucket to be set. """ self.event = asyncio.Event() self.event.set() - self.major_parameters: Dict[str, Any] = major_parameters + self.channel_id: Optional[int] = channel_id + self.guild_id: Optional[int] = guild_id + self.webhook_id: Optional[int] = webhook_id + self.webhook_token: Optional[str] = webhook_token + self.major_parameters: Dict[str, Optional[Union[int, str]]] = clear_none_values( + { + "channel_id": channel_id, + "guild_id": guild_id, + "webhook_id": webhook_id, + "webhook_token": webhook_token, + } + ) async def wait(self): """ @@ -189,7 +225,9 @@ def __eq__(self, other: TopLevelBucket): return False def __str__(self): - return f"channel_id={self.major_parameters.get('channel_id')}, guild_id={self.major_parameters.get('guild_id')}, webhook_id={self.major_parameters.get('webhook_id')}, webhook_token={self.major_parameters.get('webhook_token')}" + return ( + f"{self.channel_id}:{self.guild_id}:{self.webhook_id}:{self.webhook_token}" + ) class HTTPClient: @@ -233,7 +271,7 @@ async def request( json: Optional[Dict] = None, files: Optional[List[File]] = None, **kwargs, - ) -> Optional[aiohttp.ClientResponse]: + ) -> aiohttp.ClientResponse: """ Parameters ---------- @@ -250,8 +288,8 @@ async def request( Returns ------- - Optional[aiohttp.ClientResponse] - The response of the request. Can be None if the request fails. + aiohttp.ClientResponse + The response of the request. Raises ------ @@ -299,7 +337,9 @@ async def request( elif not response.ok: error = self.error_mapping.get(response.status, HTTPException) raise error(response, data) - return response + elif response.ok: + return response + raise TimeoutError("Attempted request 5 times .") async def set_bucket( self, @@ -315,15 +355,11 @@ async def set_bucket( if channel_id or guild_id or webhook_id or webhook_token: bucket = TopLevelBucket( - major_parameters=clear_none_values( - { - "channel_id": channel_id, - "guild_id": guild_id, - "webhook_id": webhook_id, - "webhook_token": webhook_token, - } + channel_id=channel_id, + guild_id=guild_id, + webhook_id=webhook_id, + webhook_token=webhook_token, ) - ) else: @@ -421,7 +457,6 @@ def setup_kwargs( attachment = { "filename": file.filename, "id": i, - "ephemeral": file.ephemeral, } if file.description: attachment["description"] = file.description @@ -435,4 +470,6 @@ def setup_kwargs( async def close(self) -> None: await self.session.close() - \ No newline at end of file + async def get_gateway(self) -> str: + response = await self.request(Route("GET", "/gateway")) + return (await response.json())["url"] From 8da189ef2d46422db8a97299e7ce2287a664f203 Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Sat, 24 Dec 2022 23:42:37 +0600 Subject: [PATCH 032/335] Separate buckets from HTTP.py --- EpikCord/client/buckets.py | 154 +++++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 EpikCord/client/buckets.py diff --git a/EpikCord/client/buckets.py b/EpikCord/client/buckets.py new file mode 100644 index 00000000..218db1a5 --- /dev/null +++ b/EpikCord/client/buckets.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +from ..utils import clear_none_values + +import asyncio +from logging import getLogger +from typing import Union, Optional, Dict + + +logger = getLogger("EpikCord.http") + +class MockBucket: + async def wait(self): + ... + + def clear(self): + ... + + def set(self): + ... + + def __eq__(self, other: MockBucket): + return isinstance(other, MockBucket) + + +class Bucket: + def __init__(self, *, bucket_hash: str): + """ + Parameters + ---------- + bucket_hash: str + The bucket hash of the bucket provided by Discord. + + Attributes + ---------- + bucket_hash: str + The bucket hash of the bucket provided by Discord. + event: asyncio.Event + The event that is used to wait for the bucket to be set. + """ + self.hash: str = bucket_hash + self.event: asyncio.Event = asyncio.Event() + self.event.set() + + async def wait(self): + """ + Waits for the bucket to be set. + """ + logger.info(f"Waiting for bucket {self.hash} to be set.") + await self.event.wait() + logger.info(f"Done waiting for bucket {self.hash}.") + + def set(self): + """ + Sets the bucket. + """ + logger.info(f"Setting bucket {self.hash}.") + self.event.set() + + def clear(self): + """ + Clears the bucket. + """ + logger.info(f"Clearing bucket {self.hash}.") + self.event.clear() + + def __eq__(self, other: Bucket): + if isinstance(other, Bucket): + return self.hash == other.hash + return False + + +class TopLevelBucket: + def __init__( + self, + *, + channel_id: Optional[int] = None, + guild_id: Optional[int] = None, + webhook_id: Optional[int] = None, + webhook_token: Optional[str] = None, + ): + """ + Parameters + ---------- + channel_id: Optional[int] + The channel ID of the bucket. + guild_id: Optional[int] + The guild ID of the bucket. + webhook_id: Optional[int] = None + The webhook ID of the bucket. + webhook_token: Optional[str] + The webhook token of the bucket. + + Attributes + ---------- + channel_id: Optional[int] + The channel ID of the bucket. + guild_id: Optional[int] + The guild ID of the bucket. + webhook_id: Optional[int] = None + The webhook ID of the bucket. + webhook_token: Optional[str] + The webhook token of the bucket. + major_parameters: Dict[str, Any] + The major parameters for this Bucket + event: asyncio.Event + The event that is used to wait for the bucket to be set. + """ + self.event = asyncio.Event() + self.event.set() + self.channel_id: Optional[int] = channel_id + self.guild_id: Optional[int] = guild_id + self.webhook_id: Optional[int] = webhook_id + self.webhook_token: Optional[str] = webhook_token + self.major_parameters: Dict[str, Optional[Union[int, str]]] = clear_none_values( + { + "channel_id": channel_id, + "guild_id": guild_id, + "webhook_id": webhook_id, + "webhook_token": webhook_token, + } + ) + + async def wait(self): + """ + Waits for the bucket to be set. + """ + logger.info(f"Waiting for bucket {self.major_parameters} to be set.") + await self.event.wait() + logger.info(f"Done waiting for bucket {self.major_parameters}.") + + def set(self): + """ + Sets the bucket. + """ + logger.info(f"Setting bucket {self.major_parameters}.") + self.event.set() + + def clear(self): + """ + Clears the bucket. + """ + logger.info(f"Clearing bucket {self.major_parameters}.") + self.event.clear() + + def __eq__(self, other: TopLevelBucket): + if isinstance(other, TopLevelBucket): + return self.major_parameters == other.major_parameters + return False + + def __str__(self): + return ( + f"{self.channel_id}:{self.guild_id}:{self.webhook_id}:{self.webhook_token}" + ) From d3b299a84b8195d8198acf078ac3654773d8c602 Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Sat, 24 Dec 2022 23:43:26 +0600 Subject: [PATCH 033/335] Log request --- EpikCord/client/http.py | 148 +--------------------------------------- 1 file changed, 3 insertions(+), 145 deletions(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 97c0e247..53dcf701 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -7,6 +7,7 @@ from logging import getLogger from typing import Any, Dict, List, Optional, Type, Union +from .buckets import TopLevelBucket, Bucket, MockBucket from .. import __version__ from ..exceptions import BadRequest, Forbidden, HTTPException, NotFound, Unauthorized from ..utils import clear_none_values @@ -85,151 +86,6 @@ def __init__( self.filename: str = filename self.description: Optional[str] = description -class MockBucket: - async def wait(self): - ... - - def clear(self): - ... - - def set(self): - ... - - def __eq__(self, other: MockBucket): - return isinstance(other, MockBucket) - - -class Bucket: - def __init__(self, *, bucket_hash: str): - """ - Parameters - ---------- - bucket_hash: str - The bucket hash of the bucket provided by Discord. - - Attributes - ---------- - bucket_hash: str - The bucket hash of the bucket provided by Discord. - event: asyncio.Event - The event that is used to wait for the bucket to be set. - """ - self.hash: str = bucket_hash - self.event: asyncio.Event = asyncio.Event() - self.event.set() - - async def wait(self): - """ - Waits for the bucket to be set. - """ - logger.info(f"Waiting for bucket {self.hash} to be set.") - await self.event.wait() - logger.info(f"Done waiting for bucket {self.hash}.") - - def set(self): - """ - Sets the bucket. - """ - logger.info(f"Setting bucket {self.hash}.") - self.event.set() - - def clear(self): - """ - Clears the bucket. - """ - logger.info(f"Clearing bucket {self.hash}.") - self.event.clear() - - def __eq__(self, other: Bucket): - if isinstance(other, Bucket): - return self.hash == other.hash - return False - - -class TopLevelBucket: - def __init__( - self, - *, - channel_id: Optional[int] = None, - guild_id: Optional[int] = None, - webhook_id: Optional[int] = None, - webhook_token: Optional[str] = None, - ): - """ - Parameters - ---------- - channel_id: Optional[int] - The channel ID of the bucket. - guild_id: Optional[int] - The guild ID of the bucket. - webhook_id: Optional[int] = None - The webhook ID of the bucket. - webhook_token: Optional[str] - The webhook token of the bucket. - - Attributes - ---------- - channel_id: Optional[int] - The channel ID of the bucket. - guild_id: Optional[int] - The guild ID of the bucket. - webhook_id: Optional[int] = None - The webhook ID of the bucket. - webhook_token: Optional[str] - The webhook token of the bucket. - major_parameters: Dict[str, Any] - The major parameters for this Bucket - event: asyncio.Event - The event that is used to wait for the bucket to be set. - """ - self.event = asyncio.Event() - self.event.set() - self.channel_id: Optional[int] = channel_id - self.guild_id: Optional[int] = guild_id - self.webhook_id: Optional[int] = webhook_id - self.webhook_token: Optional[str] = webhook_token - self.major_parameters: Dict[str, Optional[Union[int, str]]] = clear_none_values( - { - "channel_id": channel_id, - "guild_id": guild_id, - "webhook_id": webhook_id, - "webhook_token": webhook_token, - } - ) - - async def wait(self): - """ - Waits for the bucket to be set. - """ - logger.info(f"Waiting for bucket {self.major_parameters} to be set.") - await self.event.wait() - logger.info(f"Done waiting for bucket {self.major_parameters}.") - - def set(self): - """ - Sets the bucket. - """ - logger.info(f"Setting bucket {self.major_parameters}.") - self.event.set() - - def clear(self): - """ - Clears the bucket. - """ - logger.info(f"Clearing bucket {self.major_parameters}.") - self.event.clear() - - def __eq__(self, other: TopLevelBucket): - if isinstance(other, TopLevelBucket): - return self.major_parameters == other.major_parameters - return False - - def __str__(self): - return ( - f"{self.channel_id}:{self.guild_id}:{self.webhook_id}:{self.webhook_token}" - ) - - class HTTPClient: error_mapping: Dict[ int, @@ -330,6 +186,8 @@ async def request( data = await self.extract_content(response) + await self.log_request(response, data) + if response.status == 429: await self.handle_ratelimit(data, bucket) elif response.headers.get("X-RateLimit-Remaining", "1") == "0": From 4799135ed9987a633dda01571b96f16f86020649 Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Sat, 24 Dec 2022 23:44:53 +0600 Subject: [PATCH 034/335] Use different error --- EpikCord/client/http.py | 4 ++-- EpikCord/exceptions.py | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 53dcf701..78c0f0ef 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -9,7 +9,7 @@ from .buckets import TopLevelBucket, Bucket, MockBucket from .. import __version__ -from ..exceptions import BadRequest, Forbidden, HTTPException, NotFound, Unauthorized +from ..exceptions import BadRequest, Forbidden, HTTPException, NotFound, Unauthorized, TooManyRetries from ..utils import clear_none_values from .websocket import GatewayWebsocket @@ -197,7 +197,7 @@ async def request( raise error(response, data) elif response.ok: return response - raise TimeoutError("Attempted request 5 times .") + raise TooManyRetries(f"Attempted {method} request to {url} 5 times but failed.") async def set_bucket( self, diff --git a/EpikCord/exceptions.py b/EpikCord/exceptions.py index 087af17d..7894a432 100644 --- a/EpikCord/exceptions.py +++ b/EpikCord/exceptions.py @@ -26,3 +26,6 @@ class Unauthorized(HTTPException): class BadRequest(HTTPException): ... + +class TooManyRetries(EpikCordException): + ... \ No newline at end of file From d8bcf3ae4afa6b29c72f1b09c3b5ee7894921223 Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Sat, 24 Dec 2022 23:47:36 +0600 Subject: [PATCH 035/335] Separate file to file.py --- EpikCord/client/http.py | 38 +------------------------------------- EpikCord/file.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 37 deletions(-) create mode 100644 EpikCord/file.py diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 78c0f0ef..b08fb7c0 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -1,15 +1,14 @@ from __future__ import annotations import asyncio -import mimetypes from importlib.util import find_spec -from io import IOBase from logging import getLogger from typing import Any, Dict, List, Optional, Type, Union from .buckets import TopLevelBucket, Bucket, MockBucket from .. import __version__ from ..exceptions import BadRequest, Forbidden, HTTPException, NotFound, Unauthorized, TooManyRetries +from ..file import File from ..utils import clear_none_values from .websocket import GatewayWebsocket @@ -51,41 +50,6 @@ def __init__( } ) - -class File: - def __init__( - self, - filename: str, - contents: IOBase, - *, - description: Optional[str] = None, - mime_type: Optional[str] = None, - spoiler: bool = False, - ): - """ - Parameters - ---------- - filename: str - The filename of the file. - contents: IOBase - The contents of the file. - mime_type: Optional[str] - The mime type of the file. If not provided, it will be guessed. - spoiler: bool - Whether or not the file is a spoiler. - ephemeral: bool - Whether or not the file is ephemeral. - description: Optional[str] - The description of the file. - """ - self.contents: IOBase = contents - self.mime_type: Optional[str] = mime_type or mimetypes.guess_type(filename)[0] - if spoiler: - self.filename: str = f"SPOILER_{filename}" - else: - self.filename: str = filename - self.description: Optional[str] = description - class HTTPClient: error_mapping: Dict[ int, diff --git a/EpikCord/file.py b/EpikCord/file.py new file mode 100644 index 00000000..8d86ef98 --- /dev/null +++ b/EpikCord/file.py @@ -0,0 +1,37 @@ +import mimetypes +import io +from typing import Optional + +class File: + def __init__( + self, + filename: str, + contents: io.IOBase, + *, + description: Optional[str] = None, + mime_type: Optional[str] = None, + spoiler: bool = False, + ): + """ + Parameters + ---------- + filename: str + The filename of the file. + contents: io.IOBase + The contents of the file. + mime_type: Optional[str] + The mime type of the file. If not provided, it will be guessed. + spoiler: bool + Whether or not the file is a spoiler. + ephemeral: bool + Whether or not the file is ephemeral. + description: Optional[str] + The description of the file. + """ + self.contents: io.IOBase = contents + self.mime_type: Optional[str] = mime_type or mimetypes.guess_type(filename)[0] + if spoiler: + self.filename: str = f"SPOILER_{filename}" + else: + self.filename: str = filename + self.description: Optional[str] = description \ No newline at end of file From 047da431cd7e85f7f6ece6dd626087b6411fcbed Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sat, 24 Dec 2022 23:47:55 +0600 Subject: [PATCH 036/335] Remove whitespace Co-authored-by: Emre Terzioglu <50607143+EmreTech@users.noreply.github.com> --- EpikCord/client/http.py | 1 - 1 file changed, 1 deletion(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 78c0f0ef..aa5dab9b 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -176,7 +176,6 @@ async def request( await bucket.wait() for _ in range(5): - async with self.session.request(method, url, *args, **kwargs) as response: if isinstance(bucket, MockBucket): bucket = await self.set_bucket( From 8899c013ed6ee29104872985f4d5bf73b90e0dee Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sat, 24 Dec 2022 23:49:22 +0600 Subject: [PATCH 037/335] Fully typehint json parameter Co-authored-by: Emre Terzioglu <50607143+EmreTech@users.noreply.github.com> --- EpikCord/client/http.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 29f5f347..df90e81c 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -99,8 +99,8 @@ async def request( The args to pass to the ClientSession.request method. discord: bool Whether this request is to Discord. - json: Optional[Dict] - The json to pass to the ClientSession.request method. + json: Optional[Dict[:class:`str`, Any]] + The json document to pass to the ClientSession.request method. files: Optional[List[File]] The files to pass to the request. **kwargs From 83916d40e39f4273bc399e9a6c601b15cfa26104 Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Sat, 24 Dec 2022 23:50:29 +0600 Subject: [PATCH 038/335] Rename types.Coroutine --- EpikCord/utils/types.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/EpikCord/utils/types.py b/EpikCord/utils/types.py index c76cf4a8..64a6b51b 100644 --- a/EpikCord/utils/types.py +++ b/EpikCord/utils/types.py @@ -1,4 +1,4 @@ from typing import Any, Callable -from typing import Coroutine as _Coroutine +from typing import Coroutine -Coroutine = Callable[..., _Coroutine[Any, Any, Any]] +AsyncFunction = Callable[..., Coroutine[Any, Any, Any]] From ce08a44b0b72a3de67c21ebbc795ac90675212ba Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Sun, 25 Dec 2022 00:00:13 +0600 Subject: [PATCH 039/335] Move json_serialize to utils file --- EpikCord/client/http.py | 7 +++---- EpikCord/utils/loose.py | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index df90e81c..19c1f58c 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -5,11 +5,12 @@ from logging import getLogger from typing import Any, Dict, List, Optional, Type, Union + from .buckets import TopLevelBucket, Bucket, MockBucket from .. import __version__ from ..exceptions import BadRequest, Forbidden, HTTPException, NotFound, Unauthorized, TooManyRetries from ..file import File -from ..utils import clear_none_values +from ..utils import clear_none_values, json_serialize from .websocket import GatewayWebsocket _ORJSON = find_spec("orjson") @@ -64,9 +65,7 @@ def __init__(self, token: str, *, version: int = 10): "Authorization": f"Bot {self.token}", "User-Agent": f"DiscordBot (https://github.com/EpikCord/EpikCord.py {__version__})", }, - json_serialize=lambda x, *__, **___: json.dumps(x).decode("utf-8") # type: ignore - if _ORJSON - else json.dumps(x), + json_serialize=json_serialize, # type: ignore ws_response_class=GatewayWebsocket, ) self.buckets: Dict[str, Union[Bucket, TopLevelBucket]] = {} diff --git a/EpikCord/utils/loose.py b/EpikCord/utils/loose.py index f9a6d887..8b5c0628 100644 --- a/EpikCord/utils/loose.py +++ b/EpikCord/utils/loose.py @@ -1,5 +1,21 @@ +from importlib.util import find_spec + +_ORJSON = find_spec("orjson") + +if _ORJSON: + import orjson as json +else: + import json + def clear_none_values(d: dict): """ Clears all the values in a dictionary that are None. """ return {k: v for k, v in d.items() if v is not None} + +def json_serialize(data, *args, **kwargs): + return ( + json.dumps(data).decode("utf-8") # type: ignore + if _ORJSON + else json.dumps(data) + ) \ No newline at end of file From 5d663def8039e988f88991d6cb90fd611af0c3fb Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sun, 25 Dec 2022 00:00:56 +0600 Subject: [PATCH 040/335] Typehint json Co-authored-by: Emre Terzioglu <50607143+EmreTech@users.noreply.github.com> --- EpikCord/client/http.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index df90e81c..62abf9e6 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -88,7 +88,7 @@ async def request( route: Route, *args, discord: bool = True, - json: Optional[Dict] = None, + json: Optional[Dict[str, Any]] = None, files: Optional[List[File]] = None, **kwargs, ) -> aiohttp.ClientResponse: From a627fed79c18df3317ef3835d17e750c99164a16 Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Sun, 25 Dec 2022 00:14:39 +0600 Subject: [PATCH 041/335] Remove redundant code --- EpikCord/client/http.py | 38 ++++++++------------------------------ EpikCord/utils/loose.py | 13 ++++++++++++- 2 files changed, 20 insertions(+), 31 deletions(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 19c1f58c..68594731 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -10,16 +10,9 @@ from .. import __version__ from ..exceptions import BadRequest, Forbidden, HTTPException, NotFound, Unauthorized, TooManyRetries from ..file import File -from ..utils import clear_none_values, json_serialize +from ..utils import clear_none_values, json_serialize, clean_url from .websocket import GatewayWebsocket -_ORJSON = find_spec("orjson") - -if _ORJSON: - import orjson as json -else: - import json - import aiohttp logger = getLogger("EpikCord.http") @@ -73,14 +66,9 @@ def __init__(self, token: str, *, version: int = 10): self.global_event.set() async def ws_connect( - self, url: str, headers: Dict = {}, **kwargs + self, url: str, **kwargs ) -> aiohttp.ClientWebSocketResponse: - headers.update( - { - "User-Agent": f"DiscordBot (https://github.com/EpikCord/EpikCord.py {__version__})" - } - ) - return await self.session.ws_connect(url, headers=headers, **kwargs) + return await self.session.ws_connect(url, **kwargs) async def request( self, @@ -132,7 +120,7 @@ async def request( self.setup_kwargs(kwargs, files=files, json=json) - url = self.clean_url(url) + url = clean_url(url, self.version) bucket = self.buckets.get(f"{method}:{url}") or MockBucket() await self.global_event.wait() @@ -172,6 +160,7 @@ async def set_bucket( ) -> Union[Bucket, TopLevelBucket]: url = response.url method = response.method + bucket_key: str = f"{method}:{url}" if channel_id or guild_id or webhook_id or webhook_token: bucket = TopLevelBucket( @@ -185,11 +174,11 @@ async def set_bucket( bucket = Bucket(bucket_hash=response.headers["X-RateLimit-Bucket"]) if bucket in self.buckets.values(): - self.buckets[f"{method}:{url}"] = list(self.buckets.values())[ + self.buckets[bucket_key] = list(self.buckets.values())[ list(self.buckets.values()).index(bucket) ] - bucket = self.buckets[f"{method}:{url}"] - self.buckets[f"{method}:{url}"] = bucket + bucket = self.buckets[bucket_key] + self.buckets[bucket_key] = bucket return bucket @@ -220,17 +209,6 @@ async def handle_ratelimit( self.global_event.set() bucket.set() - def clean_url(self, url: str) -> str: - if url.startswith("/"): - url = f"https://discord.com/api/v{self.version}{url}" - else: - url = f"https://discord.com/api/v{self.version}/{url}" - - if url.endswith("/"): - url = url[:-1] - - return url - @staticmethod async def log_request(res, body: Optional[dict] = None): messages = [ diff --git a/EpikCord/utils/loose.py b/EpikCord/utils/loose.py index 8b5c0628..61c1df66 100644 --- a/EpikCord/utils/loose.py +++ b/EpikCord/utils/loose.py @@ -18,4 +18,15 @@ def json_serialize(data, *args, **kwargs): json.dumps(data).decode("utf-8") # type: ignore if _ORJSON else json.dumps(data) - ) \ No newline at end of file + ) + +def clean_url(url: str, version: int) -> str: + if url.startswith("/"): + url = f"https://discord.com/api/v{version}{url}" + else: + url = f"https://discord.com/api/v{version}/{url}" + + if url.endswith("/"): + url = url[:-1] + + return url From fd9974f97fdb7436f6d241e348ab4fdb18f39e76 Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Sun, 25 Dec 2022 00:19:19 +0600 Subject: [PATCH 042/335] Add Bucket and TopLevelBucket.handle_exhaustion --- EpikCord/client/buckets.py | 29 ++++++++++++++++++++++++++++- EpikCord/client/http.py | 12 +++--------- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/EpikCord/client/buckets.py b/EpikCord/client/buckets.py index 218db1a5..0a92686a 100644 --- a/EpikCord/client/buckets.py +++ b/EpikCord/client/buckets.py @@ -6,6 +6,7 @@ from logging import getLogger from typing import Union, Optional, Dict +import aiohttp logger = getLogger("EpikCord.http") @@ -22,7 +23,6 @@ def set(self): def __eq__(self, other: MockBucket): return isinstance(other, MockBucket) - class Bucket: def __init__(self, *, bucket_hash: str): """ @@ -69,6 +69,19 @@ def __eq__(self, other: Bucket): return self.hash == other.hash return False + async def handle_exhaustion(self, retry_after: int): + """ + Handles the exhaustion of the bucket. + + Parameters + ---------- + retry_after: int + The amount of time to wait before retrying. + """ + self.clear() + logger.info(f"Bucket {self.hash} exhausted, waiting {retry_after} seconds.") + await asyncio.sleep(retry_after) + self.set() class TopLevelBucket: def __init__( @@ -152,3 +165,17 @@ def __str__(self): return ( f"{self.channel_id}:{self.guild_id}:{self.webhook_id}:{self.webhook_token}" ) + + async def handle_exhaustion(self, retry_after: int): + """ + Handles the exhaustion of the bucket. + + Parameters + ---------- + retry_after: int + The amount of time to wait before retrying. + """ + self.clear() + logger.info(f"Bucket {str(self)} exhausted, waiting {retry_after} seconds.") + await asyncio.sleep(retry_after) + self.set() \ No newline at end of file diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 68594731..da242ae3 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -141,7 +141,7 @@ async def request( if response.status == 429: await self.handle_ratelimit(data, bucket) elif response.headers.get("X-RateLimit-Remaining", "1") == "0": - await self.handle_exhausted_bucket(response, bucket) + await bucket.handle_exhaustion(int(response.headers["X-RateLimit-Reset-After"])) elif not response.ok: error = self.error_mapping.get(response.status, HTTPException) raise error(response, data) @@ -182,20 +182,14 @@ async def set_bucket( return bucket - async def extract_content(self, response: aiohttp.ClientResponse) -> Dict[str, Any]: + @staticmethod + async def extract_content(response: aiohttp.ClientResponse) -> Dict[str, Any]: if response.headers["Content-Type"] == "application/json": data = await response.json() else: data = {} return data - async def handle_exhausted_bucket( - self, response: aiohttp.ClientResponse, bucket: Union[Bucket, TopLevelBucket] - ): - bucket.clear() - await asyncio.sleep(int(response.headers["X-RateLimit-Reset-After"])) - bucket.set() - async def handle_ratelimit( self, data: Dict[str, Any], bucket: Union[Bucket, TopLevelBucket] ): From b8d9c0ec539564c00d93cf3d0ddd6abf360e35bb Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Sun, 25 Dec 2022 00:24:36 +0600 Subject: [PATCH 043/335] Make extract_content a util function --- EpikCord/client/http.py | 12 ++---------- EpikCord/utils/loose.py | 10 ++++++++++ 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 433c157f..767b572a 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -10,7 +10,7 @@ from .. import __version__ from ..exceptions import BadRequest, Forbidden, HTTPException, NotFound, Unauthorized, TooManyRetries from ..file import File -from ..utils import clear_none_values, json_serialize, clean_url +from ..utils import clear_none_values, json_serialize, clean_url, extract_content from .websocket import GatewayWebsocket import aiohttp @@ -134,7 +134,7 @@ async def request( **route.major_parameters, ) - data = await self.extract_content(response) + data = await extract_content(response) await self.log_request(response, data) @@ -182,14 +182,6 @@ async def set_bucket( return bucket - @staticmethod - async def extract_content(response: aiohttp.ClientResponse) -> Dict[str, Any]: - if response.headers["Content-Type"] == "application/json": - data = await response.json() - else: - data = {} - return data - async def handle_ratelimit( self, data: Dict[str, Any], bucket: Union[Bucket, TopLevelBucket] ): diff --git a/EpikCord/utils/loose.py b/EpikCord/utils/loose.py index 61c1df66..289fc1a4 100644 --- a/EpikCord/utils/loose.py +++ b/EpikCord/utils/loose.py @@ -1,4 +1,5 @@ from importlib.util import find_spec +from typing import Dict, Any _ORJSON = find_spec("orjson") @@ -7,6 +8,8 @@ else: import json +import aiohttp + def clear_none_values(d: dict): """ Clears all the values in a dictionary that are None. @@ -30,3 +33,10 @@ def clean_url(url: str, version: int) -> str: url = url[:-1] return url + +async def extract_content(response: aiohttp.ClientResponse) -> Dict[str, Any]: + if response.headers["Content-Type"] == "application/json": + data = await response.json() + else: + data = {} + return data \ No newline at end of file From cf947f7e0201e6c882542e49c1999acf30500acd Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Sun, 25 Dec 2022 00:29:13 +0600 Subject: [PATCH 044/335] Add HTTP attribute to Client --- EpikCord/client/client.py | 5 ++++- EpikCord/client/http.py | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index f83f7c91..14f14ec0 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -1,7 +1,10 @@ +from .http import HTTPClient from ..flags import Intents class Client: - def __init__(self, token: str, intents: Intents): + def __init__(self, token: str, intents: Intents, *, version: int = 10): self.token: str = token self.intents: Intents = intents + self.http: HTTPClient = HTTPClient(token, version=version) + \ No newline at end of file diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 767b572a..5480ed9f 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -256,4 +256,5 @@ async def close(self) -> None: async def get_gateway(self) -> str: response = await self.request(Route("GET", "/gateway")) - return (await response.json())["url"] + data = await response.json() + return data["url"] From 424c28badc3d6adfc79d6a52f32e3bf7ff91de03 Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Sun, 25 Dec 2022 00:29:56 +0600 Subject: [PATCH 045/335] Remove unused import --- EpikCord/client/http.py | 1 - 1 file changed, 1 deletion(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 5480ed9f..3216b8ab 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -from importlib.util import find_spec from logging import getLogger from typing import Any, Dict, List, Optional, Type, Union From 98e04587b3593d71f1b18bc4f2827a9c80f8a946 Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Sun, 25 Dec 2022 00:31:17 +0600 Subject: [PATCH 046/335] Make workflows use pyright instead of mypy --- .github/workflows/ci.yml | 2 +- noxfile.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 960cd321..1e393bfb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,4 +29,4 @@ jobs: run: pip install nox git+https://github.com/Bluenix2/discord-typings - name: Run nox - run: nox -s lint mypy + run: nox -s lint myright diff --git a/noxfile.py b/noxfile.py index e00500d9..c6365c6b 100644 --- a/noxfile.py +++ b/noxfile.py @@ -15,6 +15,6 @@ def lint(session: nox.Session): @nox.session -def mypy(session: nox.Session): - session.install("mypy") - session.run("mypy", "EpikCord") +def pyright(session: nox.Session): + session.install("pyright") + session.run("pyright", "EpikCord") From 8a95424b00a580e7dedee3925218861057561f70 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sun, 25 Dec 2022 00:40:02 +0600 Subject: [PATCH 047/335] Update type for error_mapping Co-authored-by: Yohann Boniface --- EpikCord/client/http.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 3216b8ab..a3a48063 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -44,10 +44,9 @@ def __init__( ) class HTTPClient: - error_mapping: Dict[ - int, - Union[Type[NotFound], Type[Forbidden], Type[Unauthorized], Type[BadRequest]], - ] = {400: BadRequest, 401: Unauthorized, 403: Forbidden, 404: NotFound} + error_mapping: Dict[int, HTTPException] = { + 400: BadRequest, 401: Unauthorized, 403: Forbidden, 404: NotFound + } def __init__(self, token: str, *, version: int = 10): self.token: str = token From 1aebc25c70e938465505fffac3f5a38fcba8ec59 Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Sun, 25 Dec 2022 00:42:57 +0600 Subject: [PATCH 048/335] CUrrent state --- EpikCord/client/buckets.py | 11 +++++++---- EpikCord/client/client.py | 3 +-- EpikCord/client/http.py | 29 ++++++++++++++++++----------- EpikCord/exceptions.py | 3 ++- EpikCord/file.py | 5 +++-- EpikCord/utils/loose.py | 13 ++++++++----- EpikCord/utils/types.py | 3 +-- noxfile.py | 3 +-- 8 files changed, 41 insertions(+), 29 deletions(-) diff --git a/EpikCord/client/buckets.py b/EpikCord/client/buckets.py index 0a92686a..5800d1b9 100644 --- a/EpikCord/client/buckets.py +++ b/EpikCord/client/buckets.py @@ -1,15 +1,16 @@ from __future__ import annotations -from ..utils import clear_none_values - import asyncio from logging import getLogger -from typing import Union, Optional, Dict +from typing import Dict, Optional, Union import aiohttp +from ..utils import clear_none_values + logger = getLogger("EpikCord.http") + class MockBucket: async def wait(self): ... @@ -23,6 +24,7 @@ def set(self): def __eq__(self, other: MockBucket): return isinstance(other, MockBucket) + class Bucket: def __init__(self, *, bucket_hash: str): """ @@ -83,6 +85,7 @@ async def handle_exhaustion(self, retry_after: int): await asyncio.sleep(retry_after) self.set() + class TopLevelBucket: def __init__( self, @@ -178,4 +181,4 @@ async def handle_exhaustion(self, retry_after: int): self.clear() logger.info(f"Bucket {str(self)} exhausted, waiting {retry_after} seconds.") await asyncio.sleep(retry_after) - self.set() \ No newline at end of file + self.set() diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index 14f14ec0..05362175 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -1,5 +1,5 @@ -from .http import HTTPClient from ..flags import Intents +from .http import HTTPClient class Client: @@ -7,4 +7,3 @@ def __init__(self, token: str, intents: Intents, *, version: int = 10): self.token: str = token self.intents: Intents = intents self.http: HTTPClient = HTTPClient(token, version=version) - \ No newline at end of file diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 3216b8ab..189bdbaf 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -4,16 +4,22 @@ from logging import getLogger from typing import Any, Dict, List, Optional, Type, Union +import aiohttp -from .buckets import TopLevelBucket, Bucket, MockBucket from .. import __version__ -from ..exceptions import BadRequest, Forbidden, HTTPException, NotFound, Unauthorized, TooManyRetries +from ..exceptions import ( + BadRequest, + Forbidden, + HTTPException, + NotFound, + TooManyRetries, + Unauthorized, +) from ..file import File -from ..utils import clear_none_values, json_serialize, clean_url, extract_content +from ..utils import clean_url, clear_none_values, extract_content, json_serialize +from .buckets import Bucket, MockBucket, TopLevelBucket from .websocket import GatewayWebsocket -import aiohttp - logger = getLogger("EpikCord.http") @@ -43,6 +49,7 @@ def __init__( } ) + class HTTPClient: error_mapping: Dict[ int, @@ -57,16 +64,14 @@ def __init__(self, token: str, *, version: int = 10): "Authorization": f"Bot {self.token}", "User-Agent": f"DiscordBot (https://github.com/EpikCord/EpikCord.py {__version__})", }, - json_serialize=json_serialize, # type: ignore + json_serialize=json_serialize, # type: ignore ws_response_class=GatewayWebsocket, ) self.buckets: Dict[str, Union[Bucket, TopLevelBucket]] = {} self.global_event: asyncio.Event = asyncio.Event() self.global_event.set() - async def ws_connect( - self, url: str, **kwargs - ) -> aiohttp.ClientWebSocketResponse: + async def ws_connect(self, url: str, **kwargs) -> aiohttp.ClientWebSocketResponse: return await self.session.ws_connect(url, **kwargs) async def request( @@ -140,7 +145,9 @@ async def request( if response.status == 429: await self.handle_ratelimit(data, bucket) elif response.headers.get("X-RateLimit-Remaining", "1") == "0": - await bucket.handle_exhaustion(int(response.headers["X-RateLimit-Reset-After"])) + await bucket.handle_exhaustion( + int(response.headers["X-RateLimit-Reset-After"]) + ) elif not response.ok: error = self.error_mapping.get(response.status, HTTPException) raise error(response, data) @@ -167,7 +174,7 @@ async def set_bucket( guild_id=guild_id, webhook_id=webhook_id, webhook_token=webhook_token, - ) + ) else: diff --git a/EpikCord/exceptions.py b/EpikCord/exceptions.py index 7894a432..42167e70 100644 --- a/EpikCord/exceptions.py +++ b/EpikCord/exceptions.py @@ -27,5 +27,6 @@ class Unauthorized(HTTPException): class BadRequest(HTTPException): ... + class TooManyRetries(EpikCordException): - ... \ No newline at end of file + ... diff --git a/EpikCord/file.py b/EpikCord/file.py index 8d86ef98..62f61e9b 100644 --- a/EpikCord/file.py +++ b/EpikCord/file.py @@ -1,7 +1,8 @@ -import mimetypes import io +import mimetypes from typing import Optional + class File: def __init__( self, @@ -34,4 +35,4 @@ def __init__( self.filename: str = f"SPOILER_{filename}" else: self.filename: str = filename - self.description: Optional[str] = description \ No newline at end of file + self.description: Optional[str] = description diff --git a/EpikCord/utils/loose.py b/EpikCord/utils/loose.py index 289fc1a4..981fe859 100644 --- a/EpikCord/utils/loose.py +++ b/EpikCord/utils/loose.py @@ -1,5 +1,5 @@ from importlib.util import find_spec -from typing import Dict, Any +from typing import Any, Dict _ORJSON = find_spec("orjson") @@ -10,12 +10,14 @@ import aiohttp + def clear_none_values(d: dict): """ Clears all the values in a dictionary that are None. """ return {k: v for k, v in d.items() if v is not None} + def json_serialize(data, *args, **kwargs): return ( json.dumps(data).decode("utf-8") # type: ignore @@ -23,6 +25,7 @@ def json_serialize(data, *args, **kwargs): else json.dumps(data) ) + def clean_url(url: str, version: int) -> str: if url.startswith("/"): url = f"https://discord.com/api/v{version}{url}" @@ -34,9 +37,9 @@ def clean_url(url: str, version: int) -> str: return url + async def extract_content(response: aiohttp.ClientResponse) -> Dict[str, Any]: - if response.headers["Content-Type"] == "application/json": - data = await response.json() - else: - data = {} + if response.headers["Content-Type"] != "application/json": + return {} + data = await response.json() return data \ No newline at end of file diff --git a/EpikCord/utils/types.py b/EpikCord/utils/types.py index 64a6b51b..7eeef937 100644 --- a/EpikCord/utils/types.py +++ b/EpikCord/utils/types.py @@ -1,4 +1,3 @@ -from typing import Any, Callable -from typing import Coroutine +from typing import Any, Callable, Coroutine AsyncFunction = Callable[..., Coroutine[Any, Any, Any]] diff --git a/noxfile.py b/noxfile.py index c6365c6b..35ac1281 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,9 +1,8 @@ import nox - @nox.session(name="format") def format_(session: nox.Session): - session.install("black", "isort") + session.install("black", "isort", ".", "aiohttp", "orjson") session.run("isort", "EpikCord") session.run("black", "EpikCord") From e37a7609e9b7ca18d53068542554d8ecf451b968 Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Sun, 25 Dec 2022 00:44:39 +0600 Subject: [PATCH 049/335] Remove useless elif --- EpikCord/client/http.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 189bdbaf..db0f401a 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -228,7 +228,8 @@ def setup_kwargs( ) -> None: if not json and not files: return - elif json and not files: + + if json and not files: kwargs["json"] = json elif files: if not json: From a778bc269b54f7365f2f1e7759aa82bee89b9a6d Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Sun, 25 Dec 2022 00:45:44 +0600 Subject: [PATCH 050/335] Fix type of error_mapping --- EpikCord/client/http.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 9e7d9192..52df5dfb 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -51,7 +51,7 @@ def __init__( class HTTPClient: - error_mapping: Dict[int, HTTPException] = { + error_mapping: Dict[int, Type[HTTPException]] = { 400: BadRequest, 401: Unauthorized, 403: Forbidden, 404: NotFound } From c1c4c289bbeff4d1450f41fe6813cdc3e2a76039 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sun, 25 Dec 2022 11:56:07 +0600 Subject: [PATCH 051/335] Update __eq__ typehint Co-authored-by: Yohann Boniface --- EpikCord/client/buckets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EpikCord/client/buckets.py b/EpikCord/client/buckets.py index 5800d1b9..bb8e07d4 100644 --- a/EpikCord/client/buckets.py +++ b/EpikCord/client/buckets.py @@ -66,7 +66,7 @@ def clear(self): logger.info(f"Clearing bucket {self.hash}.") self.event.clear() - def __eq__(self, other: Bucket): + def __eq__(self, other: Any) -> bool: if isinstance(other, Bucket): return self.hash == other.hash return False From a494b907280e1f6df09b65ae6b978a3a704877a2 Mon Sep 17 00:00:00 2001 From: TheUntraceable Date: Sun, 25 Dec 2022 20:21:45 +0600 Subject: [PATCH 052/335] Use MajorParameters class for major parameters --- EpikCord/client/http.py | 76 ++++++++++++++++++++--------------------- 1 file changed, 37 insertions(+), 39 deletions(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 52df5dfb..4c057673 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -22,6 +22,31 @@ logger = getLogger("EpikCord.http") +class MajorParameters: + def __init__(self, channel_id: Optional[int] = None, guild_id: Optional[int] = None, webhook_id: Optional[int] = None, webhook_token: Optional[str] = None): + """ + Parameters + ---------- + channel_id: Optional[int] + The channel id of this + """ + self.channel_id: Optional[int] = channel_id + self.guild_id: Optional[int] = guild_id + self.webhook_id: Optional[int] = webhook_id + self.webhook_token: Optional[str] = webhook_token + self.major_parameters: Dict[str, Union[int, str]] = clear_none_values( + { + "channel_id": self.channel_id, + "guild_id": self.guild_id, + "webhook_id": self.webhook_id, + "webhook_token": self.webhook_token, + } + ) + + def __eq__(self, other: Any): + if isinstance(other, MajorParameters): + return self.major_parameters == other.major_parameters + return False class Route: def __init__( @@ -29,30 +54,16 @@ def __init__( method: str, url: str, *, - channel_id: Optional[int] = None, - guild_id: Optional[int] = None, - webhook_id: Optional[int] = None, - webhook_token: Optional[str] = None, + major_parameters: MajorParameters = MajorParameters() ): self.method: str = method self.url: str = url - self.channel_id: Optional[int] = channel_id - self.guild_id: Optional[int] = guild_id - self.webhook_id: Optional[int] = webhook_id - self.webhook_token: Optional[str] = webhook_token - self.major_parameters: Dict[str, Optional[Union[int, str]]] = clear_none_values( - { - "channel_id": channel_id, - "guild_id": guild_id, - "webhook_id": webhook_id, - "webhook_token": webhook_token, - } - ) + self.major_parameters: MajorParameters = major_parameters class HTTPClient: error_mapping: Dict[int, Type[HTTPException]] = { - 400: BadRequest, 401: Unauthorized, 403: Forbidden, 404: NotFound + 400: BadRequest, 401: Unauthorized, 403: Forbidden, 404: NotFound } def __init__(self, token: str, *, version: int = 10): @@ -76,8 +87,7 @@ async def ws_connect(self, url: str, **kwargs) -> aiohttp.ClientWebSocketRespons async def request( self, route: Route, - *args, - discord: bool = True, + *, json: Optional[Dict[str, Any]] = None, files: Optional[List[File]] = None, **kwargs, @@ -85,10 +95,8 @@ async def request( """ Parameters ---------- - *args - The args to pass to the ClientSession.request method. - discord: bool - Whether this request is to Discord. + route: Route + The Route to make the request to. json: Optional[Dict[:class:`str`, Any]] The json document to pass to the ClientSession.request method. files: Optional[List[File]] @@ -118,9 +126,6 @@ async def request( method = route.method url = route.url - if not discord: - return await self.session.request(method, url, *args, **kwargs) - self.setup_kwargs(kwargs, files=files, json=json) url = clean_url(url, self.version) @@ -130,11 +135,12 @@ async def request( await bucket.wait() for _ in range(5): - async with self.session.request(method, url, *args, **kwargs) as response: + async with self.session.request(method, url, **kwargs) as response: if isinstance(bucket, MockBucket): bucket = await self.set_bucket( response=response, - **route.major_parameters, + major_parameters=route.major_parameters + ) data = await extract_content(response) @@ -158,25 +164,17 @@ async def set_bucket( self, *, response: aiohttp.ClientResponse, - channel_id: Optional[int] = None, - guild_id: Optional[int] = None, - webhook_id: Optional[int] = None, - webhook_token: Optional[str] = None, + major_parameters: Optional[MajorParameters] = None ) -> Union[Bucket, TopLevelBucket]: url = response.url method = response.method bucket_key: str = f"{method}:{url}" - if channel_id or guild_id or webhook_id or webhook_token: + if major_parameters: bucket = TopLevelBucket( - channel_id=channel_id, - guild_id=guild_id, - webhook_id=webhook_id, - webhook_token=webhook_token, + **major_parameters.major_parameters ) - else: - bucket = Bucket(bucket_hash=response.headers["X-RateLimit-Bucket"]) if bucket in self.buckets.values(): self.buckets[bucket_key] = list(self.buckets.values())[ From 7fc4fcb9bb43e518ce831f11835d9546d4ba47bc Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Wed, 18 Jan 2023 20:24:39 +0000 Subject: [PATCH 053/335] Minor fixes to buckets --- EpikCord/client/buckets.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/EpikCord/client/buckets.py b/EpikCord/client/buckets.py index bb8e07d4..4326839f 100644 --- a/EpikCord/client/buckets.py +++ b/EpikCord/client/buckets.py @@ -2,9 +2,7 @@ import asyncio from logging import getLogger -from typing import Dict, Optional, Union - -import aiohttp +from typing import Dict, Optional, Union, Any from ..utils import clear_none_values @@ -21,7 +19,7 @@ def clear(self): def set(self): ... - def __eq__(self, other: MockBucket): + def __eq__(self, other: Any): return isinstance(other, MockBucket) @@ -159,7 +157,7 @@ def clear(self): logger.info(f"Clearing bucket {self.major_parameters}.") self.event.clear() - def __eq__(self, other: TopLevelBucket): + def __eq__(self, other: Any): if isinstance(other, TopLevelBucket): return self.major_parameters == other.major_parameters return False From 9bcc4d1701c2a3775a8adf8728af7dbbd2b1d6d0 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sun, 22 Jan 2023 13:22:50 +0000 Subject: [PATCH 054/335] Add docstrings to most methods --- EpikCord/client/buckets.py | 20 +++++-- EpikCord/client/client.py | 20 +++++++ EpikCord/client/http.py | 108 ++++++++++++++++++++++++++++++++--- EpikCord/client/websocket.py | 6 ++ 4 files changed, 141 insertions(+), 13 deletions(-) create mode 100644 EpikCord/client/websocket.py diff --git a/EpikCord/client/buckets.py b/EpikCord/client/buckets.py index 4326839f..9a015c26 100644 --- a/EpikCord/client/buckets.py +++ b/EpikCord/client/buckets.py @@ -10,18 +10,26 @@ class MockBucket: + """A mock bucket that does nothing.""" async def wait(self): + """Pretends to wait for the Event to be set""" ... def clear(self): + """Pretends to clear the Event""" ... def set(self): + """Pretends to set the Event""" ... def __eq__(self, other: Any): return isinstance(other, MockBucket) + async def handle_exhaustion(self, retry_after: int): + """Pretends to handle the exhaustion of the bucket""" + ... + class Bucket: def __init__(self, *, bucket_hash: str): @@ -44,7 +52,7 @@ def __init__(self, *, bucket_hash: str): async def wait(self): """ - Waits for the bucket to be set. + Waits for the Event to be set. """ logger.info(f"Waiting for bucket {self.hash} to be set.") await self.event.wait() @@ -52,14 +60,14 @@ async def wait(self): def set(self): """ - Sets the bucket. + Sets the Event. """ logger.info(f"Setting bucket {self.hash}.") self.event.set() def clear(self): """ - Clears the bucket. + Clears the Event. """ logger.info(f"Clearing bucket {self.hash}.") self.event.clear() @@ -137,7 +145,7 @@ def __init__( async def wait(self): """ - Waits for the bucket to be set. + Waits for the Event to be set. """ logger.info(f"Waiting for bucket {self.major_parameters} to be set.") await self.event.wait() @@ -145,14 +153,14 @@ async def wait(self): def set(self): """ - Sets the bucket. + Sets the Event. """ logger.info(f"Setting bucket {self.major_parameters}.") self.event.set() def clear(self): """ - Clears the bucket. + Clears the Event. """ logger.info(f"Clearing bucket {self.major_parameters}.") self.event.clear() diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index 05362175..445abdd0 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -3,7 +3,27 @@ class Client: + """The main class of EpikCord. Use this to interact with the Discord API and Gateway.""" def __init__(self, token: str, intents: Intents, *, version: int = 10): + """ + Parameters + ---------- + token: str + The token of the bot. + intents: Intents + The intents of the bot. + version: int + The version of the Discord API to use. Defaults to 10. + + Attributes + ---------- + token: str + The token of the bot. + intents: Intents + The intents of the bot. + http: HTTPClient + The HTTP client used to interact with the Discord API. + """ self.token: str = token self.intents: Intents = intents self.http: HTTPClient = HTTPClient(token, version=version) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 4c057673..a56566a8 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -49,6 +49,7 @@ def __eq__(self, other: Any): return False class Route: + """Represents a HTTP route.""" def __init__( self, method: str, @@ -56,17 +57,61 @@ def __init__( *, major_parameters: MajorParameters = MajorParameters() ): + """ + Parameters + ---------- + method: str + The method of the route. + url: str + The url of the route. + major_parameters: MajorParameters + The major parameters of the route. + + Attributes + ---------- + method: str + The method of the route. + url: str + The url of the route. + major_parameters: MajorParameters + The major parameters of the route. + """ self.method: str = method self.url: str = url self.major_parameters: MajorParameters = major_parameters class HTTPClient: + """The HTTPClient used to make requests to the Discord API.""" + error_mapping: Dict[int, Type[HTTPException]] = { 400: BadRequest, 401: Unauthorized, 403: Forbidden, 404: NotFound } def __init__(self, token: str, *, version: int = 10): + """ + Parameters + ---------- + token: str + The token of the bot. + version: int + The version of the Discord API to use. Defaults to 10. + + Attributes + ---------- + token: str + The token of the bot. + version: int + The version of the Discord API to use. + session: aiohttp.ClientSession + The ClientSession used to make requests. + buckets: Dict[str, Union[Bucket, TopLevelBucket]] + The buckets used to ratelimit requests. + global_event: asyncio.Event + The event used to wait for the global ratelimit to end. + error_mapping: Dict[int, Type[HTTPException]] + The mapping of status codes to exceptions. + """ self.token: str = token self.version: int = version self.session: aiohttp.ClientSession = aiohttp.ClientSession( @@ -126,7 +171,7 @@ async def request( method = route.method url = route.url - self.setup_kwargs(kwargs, files=files, json=json) + self.setup_request_kwargs(kwargs, files=files, json=json) url = clean_url(url, self.version) bucket = self.buckets.get(f"{method}:{url}") or MockBucket() @@ -136,7 +181,7 @@ async def request( for _ in range(5): async with self.session.request(method, url, **kwargs) as response: - if isinstance(bucket, MockBucket): + if isinstance(bucket, MockBucket) and response.headers.get("X-RateLimit-Bucket"): bucket = await self.set_bucket( response=response, major_parameters=route.major_parameters @@ -166,19 +211,39 @@ async def set_bucket( response: aiohttp.ClientResponse, major_parameters: Optional[MajorParameters] = None ) -> Union[Bucket, TopLevelBucket]: + """Sets the bucket for the request. + + Parameters + ---------- + response: aiohttp.ClientResponse + The response of the request. + major_parameters: Optional[MajorParameters] + The major parameters of the request. + + Returns + ------- + Union[Bucket, TopLevelBucket] + The bucket for the request. + """ url = response.url method = response.method bucket_key: str = f"{method}:{url}" + bucket: Optional[Union[Bucket, TopLevelBucket]] = None + if major_parameters: bucket = TopLevelBucket( - **major_parameters.major_parameters + channel_id=major_parameters.channel_id, + guild_id=major_parameters.guild_id, + webhook_id=major_parameters.webhook_id, + webhook_token=major_parameters.webhook_token ) else: bucket = Bucket(bucket_hash=response.headers["X-RateLimit-Bucket"]) if bucket in self.buckets.values(): - self.buckets[bucket_key] = list(self.buckets.values())[ - list(self.buckets.values()).index(bucket) + listed_buckets = list(self.buckets.values()) + self.buckets[bucket_key] = listed_buckets[ + listed_buckets.index(bucket) ] bucket = self.buckets[bucket_key] self.buckets[bucket_key] = bucket @@ -186,8 +251,17 @@ async def set_bucket( return bucket async def handle_ratelimit( - self, data: Dict[str, Any], bucket: Union[Bucket, TopLevelBucket] + self, data: Dict[str, Any], bucket: Union[Bucket, TopLevelBucket, MockBucket] ): + """Handles the ratelimit for the request. + + Parameters + ---------- + data: Dict[str, Any] + The data in the response. + bucket: Union[Bucket, TopLevelBucket, MockBucket] + The bucket for the request. + """ bucket.clear() if data["global"]: @@ -200,6 +274,7 @@ async def handle_ratelimit( @staticmethod async def log_request(res, body: Optional[dict] = None): + """Logs information about the request.""" messages = [ f"Sent a {res.method} to {res.url} " f"and got a {res.status} response. ", f"Content-Type: {res.headers['Content-Type']} ", @@ -220,9 +295,20 @@ async def log_request(res, body: Optional[dict] = None): finally: logger.debug("".join(messages)) - def setup_kwargs( + def setup_request_kwargs( self, kwargs, *, files: Optional[List[File]] = None, json: Optional[Dict] = None ) -> None: + """Sets up the request kwargs. + + Parameters + ---------- + kwargs: Dict[str, Any] + The kwargs for the request. + files: Optional[List[File]] + The files to send with the request. + json: Optional[Dict] + The json to send with the request. + """ if not json and not files: return @@ -256,9 +342,17 @@ def setup_kwargs( kwargs["data"] = form async def close(self) -> None: + """Closes the session.""" await self.session.close() async def get_gateway(self) -> str: + """Gets the URL used for connecting to the Gateway. + + Returns + ------- + str + The URL used for connecting to the Gateway. + """ response = await self.request(Route("GET", "/gateway")) data = await response.json() return data["url"] diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py new file mode 100644 index 00000000..f597ce7a --- /dev/null +++ b/EpikCord/client/websocket.py @@ -0,0 +1,6 @@ + +import aiohttp + +class GatewayWebsocket(aiohttp.ClientWebSocketResponse): + """A websocket connection to the Discord gateway.""" + ... \ No newline at end of file From c1bc577b846242ecabc5bfdc138b0fb3f8c22678 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sun, 22 Jan 2023 15:46:23 +0000 Subject: [PATCH 055/335] Add return annotation for __eq__ Co-authored-by: Yohann Boniface --- EpikCord/client/buckets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EpikCord/client/buckets.py b/EpikCord/client/buckets.py index 9a015c26..e1f1a466 100644 --- a/EpikCord/client/buckets.py +++ b/EpikCord/client/buckets.py @@ -23,7 +23,7 @@ def set(self): """Pretends to set the Event""" ... - def __eq__(self, other: Any): + def __eq__(self, other: Any) -> bool: return isinstance(other, MockBucket) async def handle_exhaustion(self, retry_after: int): From 39b817554c0ed28ffb691668867fe16987d8bccd Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Mon, 23 Jan 2023 21:12:39 +0000 Subject: [PATCH 056/335] Start work on WebSockets --- EpikCord/client/buckets.py | 3 +- EpikCord/client/client.py | 25 +++++++- EpikCord/client/http.py | 107 +++++++++++++++++++++++------------ EpikCord/client/websocket.py | 99 +++++++++++++++++++++++++++++++- EpikCord/utils/loose.py | 2 +- EpikCord/utils/types.py | 2 + noxfile.py | 1 + 7 files changed, 194 insertions(+), 45 deletions(-) diff --git a/EpikCord/client/buckets.py b/EpikCord/client/buckets.py index 9a015c26..5ccd9dd3 100644 --- a/EpikCord/client/buckets.py +++ b/EpikCord/client/buckets.py @@ -2,7 +2,7 @@ import asyncio from logging import getLogger -from typing import Dict, Optional, Union, Any +from typing import Any, Dict, Optional, Union from ..utils import clear_none_values @@ -11,6 +11,7 @@ class MockBucket: """A mock bucket that does nothing.""" + async def wait(self): """Pretends to wait for the Event to be set""" ... diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index 445abdd0..77324cc7 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -1,10 +1,29 @@ from ..flags import Intents -from .http import HTTPClient +from .http import HTTPClient, APIVersion +def singleton(cls): + instance = None + + def wrapper(*args, **kwargs): + nonlocal instance + + if instance is None: + instance = cls(*args, **kwargs) + return instance + + return wrapper + + +@singleton +class TokenStore: + + def __init__(self, token: str): + self.value: str = token class Client: """The main class of EpikCord. Use this to interact with the Discord API and Gateway.""" - def __init__(self, token: str, intents: Intents, *, version: int = 10): + + def __init__(self, token: TokenStore, intents: Intents, *, version: APIVersion = APIVersion.TEN): """ Parameters ---------- @@ -24,6 +43,6 @@ def __init__(self, token: str, intents: Intents, *, version: int = 10): http: HTTPClient The HTTP client used to interact with the Discord API. """ - self.token: str = token + self.token: TokenStore = token self.intents: Intents = intents self.http: HTTPClient = HTTPClient(token, version=version) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index a56566a8..06fb165f 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -1,34 +1,64 @@ from __future__ import annotations import asyncio +from enum import IntEnum from logging import getLogger -from typing import Any, Dict, List, Optional, Type, Union +from typing import Any, Dict, List, Optional, Type, Union, TYPE_CHECKING import aiohttp from .. import __version__ -from ..exceptions import ( - BadRequest, - Forbidden, - HTTPException, - NotFound, - TooManyRetries, - Unauthorized, -) +from ..exceptions import (BadRequest, Forbidden, HTTPException, NotFound, + TooManyRetries, Unauthorized) from ..file import File -from ..utils import clean_url, clear_none_values, extract_content, json_serialize +from ..utils import (clean_url, clear_none_values, extract_content, + json_serialize) from .buckets import Bucket, MockBucket, TopLevelBucket -from .websocket import GatewayWebsocket +from .websocket import GatewayWebSocket + +if TYPE_CHECKING: + from .client import TokenStore logger = getLogger("EpikCord.http") +class APIVersion(IntEnum): + NINE = 9 + TEN = 10 + + class MajorParameters: - def __init__(self, channel_id: Optional[int] = None, guild_id: Optional[int] = None, webhook_id: Optional[int] = None, webhook_token: Optional[str] = None): - """ + def __init__( + self, + channel_id: Optional[int] = None, + guild_id: Optional[int] = None, + webhook_id: Optional[int] = None, + webhook_token: Optional[str] = None, + ): + """A class to group up all the major parameters of a route. + Parameters ---------- channel_id: Optional[int] - The channel id of this + The channel id in the route. + guild_id: Optional[int] + The guild id in the route. + webhook_id: Optional[int] + The webhook id in the route. + webhook_token: Optional[str] + The webhook token in the route. + + Attributes + ---------- + channel_id: Optional[int] + The channel id in the route. + guild_id: Optional[int] + The guild id in the route. + webhook_id: Optional[int] + The webhook id in the route. + webhook_token: Optional[str] + The webhook token in the route. + major_parameters: Dict[str, Union[int, str]] + The major parameters compiled into a single dictionary. """ self.channel_id: Optional[int] = channel_id self.guild_id: Optional[int] = guild_id @@ -48,14 +78,16 @@ def __eq__(self, other: Any): return self.major_parameters == other.major_parameters return False + class Route: """Represents a HTTP route.""" + def __init__( self, method: str, url: str, *, - major_parameters: MajorParameters = MajorParameters() + major_parameters: MajorParameters = MajorParameters(), ): """ Parameters @@ -74,7 +106,7 @@ def __init__( url: str The url of the route. major_parameters: MajorParameters - The major parameters of the route. + The major parameters of the route. """ self.method: str = method self.url: str = url @@ -85,22 +117,25 @@ class HTTPClient: """The HTTPClient used to make requests to the Discord API.""" error_mapping: Dict[int, Type[HTTPException]] = { - 400: BadRequest, 401: Unauthorized, 403: Forbidden, 404: NotFound + 400: BadRequest, + 401: Unauthorized, + 403: Forbidden, + 404: NotFound, } - def __init__(self, token: str, *, version: int = 10): + def __init__(self, token: TokenStore, *, version: APIVersion = APIVersion.TEN): """ Parameters ---------- token: str - The token of the bot. + The token of the bot. Used as authorization. Should be kept private at all times. version: int The version of the Discord API to use. Defaults to 10. Attributes ---------- token: str - The token of the bot. + The token of the bot. Used as authorization. Should be kept private at all times. version: int The version of the Discord API to use. session: aiohttp.ClientSession @@ -112,15 +147,15 @@ def __init__(self, token: str, *, version: int = 10): error_mapping: Dict[int, Type[HTTPException]] The mapping of status codes to exceptions. """ - self.token: str = token - self.version: int = version + self.token: TokenStore = token + self.version: APIVersion = version self.session: aiohttp.ClientSession = aiohttp.ClientSession( headers={ "Authorization": f"Bot {self.token}", "User-Agent": f"DiscordBot (https://github.com/EpikCord/EpikCord.py {__version__})", }, json_serialize=json_serialize, # type: ignore - ws_response_class=GatewayWebsocket, + ws_response_class=GatewayWebSocket, ) self.buckets: Dict[str, Union[Bucket, TopLevelBucket]] = {} self.global_event: asyncio.Event = asyncio.Event() @@ -173,7 +208,7 @@ async def request( self.setup_request_kwargs(kwargs, files=files, json=json) - url = clean_url(url, self.version) + url = clean_url(url, self.version.value) bucket = self.buckets.get(f"{method}:{url}") or MockBucket() await self.global_event.wait() @@ -181,11 +216,11 @@ async def request( for _ in range(5): async with self.session.request(method, url, **kwargs) as response: - if isinstance(bucket, MockBucket) and response.headers.get("X-RateLimit-Bucket"): + if isinstance(bucket, MockBucket) and response.headers.get( + "X-RateLimit-Bucket" + ): bucket = await self.set_bucket( - response=response, - major_parameters=route.major_parameters - + response=response, major_parameters=route.major_parameters ) data = await extract_content(response) @@ -209,7 +244,7 @@ async def set_bucket( self, *, response: aiohttp.ClientResponse, - major_parameters: Optional[MajorParameters] = None + major_parameters: Optional[MajorParameters] = None, ) -> Union[Bucket, TopLevelBucket]: """Sets the bucket for the request. @@ -219,7 +254,7 @@ async def set_bucket( The response of the request. major_parameters: Optional[MajorParameters] The major parameters of the request. - + Returns ------- Union[Bucket, TopLevelBucket] @@ -236,15 +271,13 @@ async def set_bucket( channel_id=major_parameters.channel_id, guild_id=major_parameters.guild_id, webhook_id=major_parameters.webhook_id, - webhook_token=major_parameters.webhook_token + webhook_token=major_parameters.webhook_token, ) else: bucket = Bucket(bucket_hash=response.headers["X-RateLimit-Bucket"]) if bucket in self.buckets.values(): listed_buckets = list(self.buckets.values()) - self.buckets[bucket_key] = listed_buckets[ - listed_buckets.index(bucket) - ] + self.buckets[bucket_key] = listed_buckets[listed_buckets.index(bucket)] bucket = self.buckets[bucket_key] self.buckets[bucket_key] = bucket @@ -254,7 +287,7 @@ async def handle_ratelimit( self, data: Dict[str, Any], bucket: Union[Bucket, TopLevelBucket, MockBucket] ): """Handles the ratelimit for the request. - + Parameters ---------- data: Dict[str, Any] @@ -311,7 +344,7 @@ def setup_request_kwargs( """ if not json and not files: return - + if json and not files: kwargs["json"] = json elif files: @@ -347,7 +380,7 @@ async def close(self) -> None: async def get_gateway(self) -> str: """Gets the URL used for connecting to the Gateway. - + Returns ------- str diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index f597ce7a..40d2a1d3 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -1,6 +1,99 @@ +from __future__ import annotations + +import asyncio +import zlib +from datetime import timedelta +from importlib.util import find_spec +from typing import Any, Optional, TYPE_CHECKING import aiohttp -class GatewayWebsocket(aiohttp.ClientWebSocketResponse): - """A websocket connection to the Discord gateway.""" - ... \ No newline at end of file +from ..presence import Presence +from ..ext import tasks +from ..flags import Intents + +_ORJSON = find_spec("orjson") + + +if _ORJSON: + import orjson as json +else: + import json # type: ignore + +if TYPE_CHECKING: + from .client import TokenStore + from .http import HTTPClient + +class GatewayRateLimiter: + def __init__(self): + + self.event = asyncio.Event() + self.event.set() + + self.remaining = 120 + self.limit = 120 + self.reset.run() + + @tasks.task(duration=timedelta(seconds=60)) + async def reset(self): + self.remaining = self.limit + self.event.set() + + async def tick(self): + await self.event.wait() + self.remaining -= 1 + if self.remaining == 0: + self.event.clear() + + +class DiscordWSMessage: + def __init__(self, *, data, type, extra): + self.data = data + self.type = type + self.extra = extra + + def json(self) -> Any: + return json.loads(self.data) + + +class GatewayWebSocket(aiohttp.ClientWebSocketResponse): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.buffer: bytearray = bytearray() + self.inflator = zlib.decompressobj() + + async def receive(self, *args, **kwargs): + ws_message = await super().receive(*args, **kwargs) + message = ws_message.data + + if isinstance(message, bytes): + + self.buffer.extend(message) + + if len(message) < 4 or message[-4:] != b"\x00\x00\xff\xff": + return + + message = self.inflator.decompress(self.buffer) + + message = message.decode("utf-8") + self.buffer: bytearray = bytearray() + + return DiscordWSMessage( + data=message, type=ws_message.type, extra=ws_message.extra + ) + + +class WebSocketClient: + def __init__(self, token: TokenStore, intents: Intents, *, presence: Optional[Presence] = None, http: HTTPClient): + self.token: TokenStore = token + self.intents: Intents = intents + self.presence: Optional[Presence] = presence + + self.ws: Optional[GatewayWebSocket] = None + self.http: HTTPClient = http + + self.rate_limiter: GatewayRateLimiter = GatewayRateLimiter() + + async def connect(self): + url = await self.http.get_gateway() + \ No newline at end of file diff --git a/EpikCord/utils/loose.py b/EpikCord/utils/loose.py index 981fe859..4cd02b9b 100644 --- a/EpikCord/utils/loose.py +++ b/EpikCord/utils/loose.py @@ -42,4 +42,4 @@ async def extract_content(response: aiohttp.ClientResponse) -> Dict[str, Any]: if response.headers["Content-Type"] != "application/json": return {} data = await response.json() - return data \ No newline at end of file + return data diff --git a/EpikCord/utils/types.py b/EpikCord/utils/types.py index 7eeef937..10c5f495 100644 --- a/EpikCord/utils/types.py +++ b/EpikCord/utils/types.py @@ -1,3 +1,5 @@ +from enum import IntEnum from typing import Any, Callable, Coroutine AsyncFunction = Callable[..., Coroutine[Any, Any, Any]] + diff --git a/noxfile.py b/noxfile.py index 35ac1281..f4f30a93 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,5 +1,6 @@ import nox + @nox.session(name="format") def format_(session: nox.Session): session.install("black", "isort", ".", "aiohttp", "orjson") From b3cffe09f717337fa4b7f3486b471d862ed5d1e1 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Wed, 25 Jan 2023 07:42:22 +0000 Subject: [PATCH 057/335] Add presence.py --- EpikCord/presence.py | 101 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 EpikCord/presence.py diff --git a/EpikCord/presence.py b/EpikCord/presence.py new file mode 100644 index 00000000..1a6a19a0 --- /dev/null +++ b/EpikCord/presence.py @@ -0,0 +1,101 @@ +from enum import Enum, IntEnum +from typing import Optional, TypedDict, List +from typing_extensions import NotRequired + +class ActivityPayload(TypedDict): # Data sent to Discord when updating presence + name: str + type: int + url: NotRequired[str] + + +class Status(Enum): + ONLINE = "online" + IDLE = "idle" + DND = "dnd" + INVISIBLE = "invisible" + OFFLINE = "offline" + + +class ActivityType(IntEnum): + GAME = 0 + STREAMING = 1 + LISTENING = 2 + WATCHING = 3 + CUSTOM = 4 + COMPETING = 5 + +class UpdatePresenceData(TypedDict): # Data sent to Discord when updating presence + status: NotRequired[str] + activities: NotRequired[List[ActivityPayload]] + afk: NotRequired[bool] + since: NotRequired[float] + +class Activity: + def __init__(self, *, name: str, type: ActivityType, url: Optional[str] = None): + """Represents a Discord activity. + + Parameters + ---------- + name: :class:`str` + The name of the activity. + type: :class:`ActivityType` + The type of the activity. + url: Optional[:class:`str`] + The url of the activity. Only used for streaming (activity type 1). + """ + self.name = name + self.type = type + self.url = url + + def to_dict(self) -> ActivityPayload: + """Converts the activity to a dictionary.""" + data: ActivityPayload = {"name": self.name, "type": self.type.value} + + if self.url is not None and not self.type == ActivityType.STREAMING: + raise ValueError("URL can only be set for streaming activities.") + + if self.url is not None: + data["url"] = self.url + + return data + + +class Presence: + def __init__(self, *, status: Optional[Status] = None, activity: Optional[Activity] = None, afk: bool = False, since: float = 0): + """Represents a Discord presence. + + Parameters + ---------- + status: Optional[:class:`Status`] + The status of the presence. + activity: Optional[:class:`Activity`] + The activity of the presence. + afk: :class:`bool` + Whether the the bot is afk or not. Defaults to False. + since: Optional[:class:`float`] + The time since the bot has been afk. Defaults to 0. + """ + if not status and not activity: + raise ValueError("Presence must have either a status or an activity.") + self.status: Optional[Status] = status + self.activity: Optional[Activity] = activity + self.afk: bool = afk + self.since: float = since + + def to_dict(self) -> UpdatePresenceData: + """Converts the presence to a dictionary.""" + data: UpdatePresenceData = {} + + if self.status is not None: + data["status"] = self.status.value + + if self.activity is not None: + data["activities"] = [self.activity.to_dict()] + + if self.afk: + data["afk"] = self.afk + + if self.since: + data["since"] = self.since + + return data \ No newline at end of file From b0e73077034497884b72bcc65206b5943171d44b Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sat, 28 Jan 2023 12:50:50 +0000 Subject: [PATCH 058/335] Add some websocket stuff --- EpikCord/client/client.py | 25 ++--- EpikCord/client/http.py | 79 +++++++++----- EpikCord/client/websocket.py | 206 ++++++++++++++++++++++++++++++++++- EpikCord/presence.py | 37 +++---- EpikCord/utils/loose.py | 13 +++ EpikCord/utils/types.py | 1 - 6 files changed, 296 insertions(+), 65 deletions(-) diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index 77324cc7..1938b079 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -1,29 +1,24 @@ from ..flags import Intents -from .http import HTTPClient, APIVersion - -def singleton(cls): - instance = None - - def wrapper(*args, **kwargs): - nonlocal instance - - if instance is None: - instance = cls(*args, **kwargs) - return instance - - return wrapper +from ..utils import singleton +from .http import APIVersion, HTTPClient @singleton class TokenStore: - def __init__(self, token: str): self.value: str = token + class Client: """The main class of EpikCord. Use this to interact with the Discord API and Gateway.""" - def __init__(self, token: TokenStore, intents: Intents, *, version: APIVersion = APIVersion.TEN): + def __init__( + self, + token: TokenStore, + intents: Intents, + *, + version: APIVersion = APIVersion.TEN + ): """ Parameters ---------- diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 06fb165f..496eae58 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -3,16 +3,22 @@ import asyncio from enum import IntEnum from logging import getLogger -from typing import Any, Dict, List, Optional, Type, Union, TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type, TypedDict, Union import aiohttp +from typing_extensions import NotRequired from .. import __version__ -from ..exceptions import (BadRequest, Forbidden, HTTPException, NotFound, - TooManyRetries, Unauthorized) +from ..exceptions import ( + BadRequest, + Forbidden, + HTTPException, + NotFound, + TooManyRetries, + Unauthorized, +) from ..file import File -from ..utils import (clean_url, clear_none_values, extract_content, - json_serialize) +from ..utils import clean_url, clear_none_values, extract_content, json_serialize from .buckets import Bucket, MockBucket, TopLevelBucket from .websocket import GatewayWebSocket @@ -21,9 +27,21 @@ logger = getLogger("EpikCord.http") + class APIVersion(IntEnum): + """Represents the version of the Discord HTTP API and Gateway WebSocket to use.""" + NINE = 9 - TEN = 10 + TEN = 10 + + +class SendingAttachmentData(TypedDict): + """The data used to send an attachment.""" + + id: int + filename: str + description: NotRequired[str] + ephemeral: NotRequired[bool] class MajorParameters: @@ -161,8 +179,8 @@ def __init__(self, token: TokenStore, *, version: APIVersion = APIVersion.TEN): self.global_event: asyncio.Event = asyncio.Event() self.global_event.set() - async def ws_connect(self, url: str, **kwargs) -> aiohttp.ClientWebSocketResponse: - return await self.session.ws_connect(url, **kwargs) + async def ws_connect(self, url: str, **kwargs) -> GatewayWebSocket: + return await self.session.ws_connect(url, **kwargs) # type: ignore async def request( self, @@ -328,9 +346,28 @@ async def log_request(res, body: Optional[dict] = None): finally: logger.debug("".join(messages)) + def add_file( + self, form: aiohttp.FormData, file: File, i: int + ) -> SendingAttachmentData: + form.add_field( + f"files[{i}]", + file.contents, + filename=file.filename, + content_type=file.mime_type, + ) + + attachment: SendingAttachmentData = { + "filename": file.filename, + "id": i, + } + if file.description: + attachment["description"] = file.description + + return attachment + def setup_request_kwargs( self, kwargs, *, files: Optional[List[File]] = None, json: Optional[Dict] = None - ) -> None: + ) -> Optional[Dict]: """Sets up the request kwargs. Parameters @@ -343,10 +380,12 @@ def setup_request_kwargs( The json to send with the request. """ if not json and not files: - return + return None + + return_kwargs = kwargs.copy() if json and not files: - kwargs["json"] = json + return_kwargs["json"] = json elif files: if not json: json = {} @@ -355,24 +394,14 @@ def setup_request_kwargs( if files: for i, file in enumerate(files): - form.add_field( - f"files[{i}]", - file.contents, - filename=file.filename, - content_type=file.mime_type, - ) - attachment = { - "filename": file.filename, - "id": i, - } - if file.description: - attachment["description"] = file.description - + attachment = self.add_file(form, file, i) json["attachments"].append(attachment) form.add_field("payload_json", self.session.json_serialize(json)) - kwargs["data"] = form + return_kwargs["data"] = form + + return return_kwargs async def close(self) -> None: """Closes the session.""" diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 40d2a1d3..4b0dad8c 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -1,16 +1,33 @@ from __future__ import annotations import asyncio +from collections import defaultdict +from enum import IntEnum +from sys import platform +from time import perf_counter_ns import zlib from datetime import timedelta +from discord_typings import HelloData, IdentifyConnectionProperties from importlib.util import find_spec -from typing import Any, Optional, TYPE_CHECKING +from typing import ( + Any, + DefaultDict, + Literal, + Optional, + TYPE_CHECKING, + List, + TypedDict, + Union, +) +from typing_extensions import NotRequired +from logging import getLogger import aiohttp -from ..presence import Presence from ..ext import tasks from ..flags import Intents +from ..presence import Presence, UpdatePresenceData +from ..utils import AsyncFunction _ORJSON = find_spec("orjson") @@ -24,6 +41,168 @@ from .client import TokenStore from .http import HTTPClient +logger = getLogger("EpikCord.websocket") + + +class OpCode(IntEnum): + DISPATCH = 0 + HEARTBEAT = 1 + IDENTIFY = 2 + STATUS_UPDATE = 3 + VOICE_STATE_UPDATE = 4 + VOICE_SERVER_PING = 5 + RESUME = 6 + RECONNECT = 7 + REQUEST_GUILD_MEMBERS = 8 + INVALID_SESSION = 9 + HELLO = 10 + HEARTBEAT_ACK = 11 + + +class IdentifyData(TypedDict): + token: str + intents: int + properties: IdentifyConnectionProperties + compress: bool + large_threshold: int + presence: NotRequired[UpdatePresenceData] + + +class IdentifyCommand(TypedDict): + op: Literal[OpCode.IDENTIFY] + d: IdentifyData + + +class WaitForEvent: + def __init__( + self, + *, + name: Optional[str] = None, + opcode: Optional[OpCode] = None, + timeout: Optional[float] = None, + check: Optional[AsyncFunction], + ): + self.event_name = name + self.opcode = opcode + self.timeout = timeout + self.future: asyncio.Future = asyncio.Future() + self.check = check + + +class GatewayEventHandler: + def __init__(self, client: WebSocketClient): + self.client = client + self.wait_for_events: DefaultDict[Union[str, int], List] = defaultdict(list) + + def wait_for( + self, + *, + name: Optional[str] = None, + opcode: Optional[OpCode] = None, + check: Optional[AsyncFunction] = None, + timeout: Optional[float] = None, + ): + if not name and not opcode: + raise ValueError("Either name or opcode must be provided.") + elif name and opcode: + raise ValueError("Only name or opcode can be provided.") + + event = WaitForEvent(name=name, timeout=timeout, check=check) + + if name: + self.wait_for_events[name].append(event) + elif opcode: + self.wait_for_events[opcode.value].append(event) + + return asyncio.wait_for(event.future, timeout=timeout) + + async def send_json(self, payload: Any): + if not self.client.ws: + logger.error("Tried to send a payload without a websocket connection.") + return + await self.client.rate_limiter.tick() + await self.client.ws.send_json(payload) + + async def identify(self): + payload: IdentifyCommand = { + "op": OpCode.IDENTIFY, + "d": { + "token": self.client.token.value, + "intents": self.client.intents.value, + "properties": { + "os": platform, + "browser": "EpikCord.py", + "device": "EpikCord.py", + }, + "compress": True, + "large_threshold": 50, + }, + } + + if self.client.presence: + payload["d"]["presence"] = self.client.presence.to_dict() + + await self.send_json(payload) + + async def hello(self, data: HelloData): + """Handle the hello event. OpCode 10.""" + self.client.heartbeat_interval = data["heartbeat_interval"] / 1000 + await self.identify() + + async def heartbeat(self, *, forced: Optional[bool] = False): + """Send a heartbeat to the gateway. + + Parameters + ---------- + forced : Optional[bool] + Whether or not to send the heartbeat now. + """ + + if not self.client.heartbeat_interval: + logger.error("Tried to send a heartbeat without an interval.") + return + + if not forced: + await asyncio.sleep(self.client.heartbeat_interval) + + await self.send_json({"op": OpCode.HEARTBEAT, "d": None}) + + start = perf_counter_ns() + + await self.wait_for(opcode=OpCode.HEARTBEAT_ACK, timeout=self.client.heartbeat_interval) + + end = perf_counter_ns() + + self.client._heartbeats.append(end - start) + + async def handle(self, message: DiscordWSMessage): + try: + event = message.json() + except json.JSONDecodeError: + logger.error("Failed to decode message: %s", message.data) + return + + if event["op"] in self.wait_for_events: + for event in self.wait_for_events[event["op"]]: + if event.check: + try: + if await event.check(event): + event.future.set_result(event) + except Exception as exception: + event.future.set_exception(exception) + else: + event.future.set_result(event) + elif event["t"].lower() in self.wait_for_events: + for event in self.wait_for_events[event["t"].lower()]: + if event.check: + try: + if await event.check(event): + event.future.set_result(event) + except Exception as exception: + event.future.set_exception(exception) + else: + event.future.set_result(event) + class GatewayRateLimiter: def __init__(self): @@ -84,7 +263,14 @@ async def receive(self, *args, **kwargs): class WebSocketClient: - def __init__(self, token: TokenStore, intents: Intents, *, presence: Optional[Presence] = None, http: HTTPClient): + def __init__( + self, + token: TokenStore, + intents: Intents, + *, + presence: Optional[Presence] = None, + http: HTTPClient, + ): self.token: TokenStore = token self.intents: Intents = intents self.presence: Optional[Presence] = presence @@ -94,6 +280,18 @@ def __init__(self, token: TokenStore, intents: Intents, *, presence: Optional[Pr self.rate_limiter: GatewayRateLimiter = GatewayRateLimiter() + self.sequence: Optional[int] = None + self.session_id: Optional[str] = None + self.heartbeat_interval: Optional[float] = None + self._heartbeats: List[int] = [] + + @property + def latency(self) -> Optional[float]: + if not self._heartbeats: + return None + return sum(self._heartbeats) / len(self._heartbeats) + async def connect(self): url = await self.http.get_gateway() - \ No newline at end of file + version = self.http.version.value + self.ws = await self.http.ws_connect(url, version=version) diff --git a/EpikCord/presence.py b/EpikCord/presence.py index 1a6a19a0..a7ba83fb 100644 --- a/EpikCord/presence.py +++ b/EpikCord/presence.py @@ -1,8 +1,10 @@ from enum import Enum, IntEnum -from typing import Optional, TypedDict, List +from typing import List, Optional, TypedDict + from typing_extensions import NotRequired -class ActivityPayload(TypedDict): # Data sent to Discord when updating presence + +class ActivityPayload(TypedDict): # Data sent to Discord when updating presence name: str type: int url: NotRequired[str] @@ -24,11 +26,13 @@ class ActivityType(IntEnum): CUSTOM = 4 COMPETING = 5 -class UpdatePresenceData(TypedDict): # Data sent to Discord when updating presence + +class UpdatePresenceData(TypedDict): # Data sent to Discord when updating presence status: NotRequired[str] activities: NotRequired[List[ActivityPayload]] - afk: NotRequired[bool] - since: NotRequired[float] + afk: bool + since: float + class Activity: def __init__(self, *, name: str, type: ActivityType, url: Optional[str] = None): @@ -61,7 +65,9 @@ def to_dict(self) -> ActivityPayload: class Presence: - def __init__(self, *, status: Optional[Status] = None, activity: Optional[Activity] = None, afk: bool = False, since: float = 0): + def __init__( + self, *, status: Optional[Status] = None, activity: Optional[Activity] = None + ): """Represents a Discord presence. Parameters @@ -70,21 +76,18 @@ def __init__(self, *, status: Optional[Status] = None, activity: Optional[Activi The status of the presence. activity: Optional[:class:`Activity`] The activity of the presence. - afk: :class:`bool` - Whether the the bot is afk or not. Defaults to False. - since: Optional[:class:`float`] - The time since the bot has been afk. Defaults to 0. """ if not status and not activity: raise ValueError("Presence must have either a status or an activity.") self.status: Optional[Status] = status self.activity: Optional[Activity] = activity - self.afk: bool = afk - self.since: float = since def to_dict(self) -> UpdatePresenceData: """Converts the presence to a dictionary.""" - data: UpdatePresenceData = {} + data: UpdatePresenceData = { + "afk": False, + "since": 0.0, + } if self.status is not None: data["status"] = self.status.value @@ -92,10 +95,4 @@ def to_dict(self) -> UpdatePresenceData: if self.activity is not None: data["activities"] = [self.activity.to_dict()] - if self.afk: - data["afk"] = self.afk - - if self.since: - data["since"] = self.since - - return data \ No newline at end of file + return data diff --git a/EpikCord/utils/loose.py b/EpikCord/utils/loose.py index 4cd02b9b..19d72cc2 100644 --- a/EpikCord/utils/loose.py +++ b/EpikCord/utils/loose.py @@ -43,3 +43,16 @@ async def extract_content(response: aiohttp.ClientResponse) -> Dict[str, Any]: return {} data = await response.json() return data + + +def singleton(cls): + instance = None + + def wrapper(*args, **kwargs): + nonlocal instance + + if instance is None: + instance = cls(*args, **kwargs) + return instance + + return wrapper diff --git a/EpikCord/utils/types.py b/EpikCord/utils/types.py index 10c5f495..bc53d269 100644 --- a/EpikCord/utils/types.py +++ b/EpikCord/utils/types.py @@ -2,4 +2,3 @@ from typing import Any, Callable, Coroutine AsyncFunction = Callable[..., Coroutine[Any, Any, Any]] - From 64c3fbab9840a907b1e09316860c341d4cb8bac5 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sat, 28 Jan 2023 12:53:52 +0000 Subject: [PATCH 059/335] Heartbeat --- EpikCord/client/websocket.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 4b0dad8c..5ffa2728 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -149,6 +149,12 @@ async def hello(self, data: HelloData): self.client.heartbeat_interval = data["heartbeat_interval"] / 1000 await self.identify() + async def heartbeat_task(): + while True: + await self.heartbeat() + + asyncio.create_task(heartbeat_task()) + async def heartbeat(self, *, forced: Optional[bool] = False): """Send a heartbeat to the gateway. From b29d6516fe5121f4771fa4cbfec612395c1a4661 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sat, 28 Jan 2023 13:14:33 +0000 Subject: [PATCH 060/335] Move types to types.py --- EpikCord/__init__.py | 3 +++ EpikCord/client/client.py | 39 +++++++++++++++++++++++---- EpikCord/client/websocket.py | 52 +++++++++--------------------------- EpikCord/utils/loose.py | 27 ++++++++++++++++++- EpikCord/utils/types.py | 36 ++++++++++++++++++++++++- 5 files changed, 110 insertions(+), 47 deletions(-) diff --git a/EpikCord/__init__.py b/EpikCord/__init__.py index 2363966e..9cf56c58 100644 --- a/EpikCord/__init__.py +++ b/EpikCord/__init__.py @@ -2,3 +2,6 @@ from .client import * from .flags import * +from .file import * +from .exceptions import * +from .ext import * diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index 1938b079..2ac6778e 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -1,6 +1,12 @@ +import asyncio +from typing import Optional + from ..flags import Intents +from ..presence import Presence from ..utils import singleton +from .websocket import WebSocketClient from .http import APIVersion, HTTPClient +from ..utils import cleanup_loop @singleton @@ -9,7 +15,7 @@ def __init__(self, token: str): self.value: str = token -class Client: +class Client(WebSocketClient): """The main class of EpikCord. Use this to interact with the Discord API and Gateway.""" def __init__( @@ -17,7 +23,8 @@ def __init__( token: TokenStore, intents: Intents, *, - version: APIVersion = APIVersion.TEN + version: APIVersion = APIVersion.TEN, + presence: Optional[Presence] = None ): """ Parameters @@ -38,6 +45,28 @@ def __init__( http: HTTPClient The HTTP client used to interact with the Discord API. """ - self.token: TokenStore = token - self.intents: Intents = intents - self.http: HTTPClient = HTTPClient(token, version=version) + super().__init__(token, intents, presence=presence, http=HTTPClient(token, version=version)) + + def login(self): + loop = asyncio.get_event_loop() + + async def runner(): + try: + await self.connect() + finally: + if not self.http.session.closed: + await self.http.close() + + def stop_loop_on_completion(f: asyncio.Future): + loop.stop() + + future = asyncio.ensure_future(runner(), loop=loop) + future.add_done_callback(stop_loop_on_completion) + + try: + loop.run_forever() + except KeyboardInterrupt: + pass + finally: + future.remove_done_callback(stop_loop_on_completion) + cleanup_loop(loop) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 5ffa2728..3a8b9c1b 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -2,32 +2,28 @@ import asyncio from collections import defaultdict -from enum import IntEnum from sys import platform from time import perf_counter_ns import zlib from datetime import timedelta -from discord_typings import HelloData, IdentifyConnectionProperties +from discord_typings import HelloData from importlib.util import find_spec from typing import ( Any, DefaultDict, - Literal, Optional, TYPE_CHECKING, List, - TypedDict, Union, ) -from typing_extensions import NotRequired from logging import getLogger import aiohttp from ..ext import tasks from ..flags import Intents -from ..presence import Presence, UpdatePresenceData -from ..utils import AsyncFunction +from ..presence import Presence +from ..utils import AsyncFunction, OpCode, IdentifyCommand _ORJSON = find_spec("orjson") @@ -43,36 +39,6 @@ logger = getLogger("EpikCord.websocket") - -class OpCode(IntEnum): - DISPATCH = 0 - HEARTBEAT = 1 - IDENTIFY = 2 - STATUS_UPDATE = 3 - VOICE_STATE_UPDATE = 4 - VOICE_SERVER_PING = 5 - RESUME = 6 - RECONNECT = 7 - REQUEST_GUILD_MEMBERS = 8 - INVALID_SESSION = 9 - HELLO = 10 - HEARTBEAT_ACK = 11 - - -class IdentifyData(TypedDict): - token: str - intents: int - properties: IdentifyConnectionProperties - compress: bool - large_threshold: int - presence: NotRequired[UpdatePresenceData] - - -class IdentifyCommand(TypedDict): - op: Literal[OpCode.IDENTIFY] - d: IdentifyData - - class WaitForEvent: def __init__( self, @@ -152,7 +118,7 @@ async def hello(self, data: HelloData): async def heartbeat_task(): while True: await self.heartbeat() - + asyncio.create_task(heartbeat_task()) async def heartbeat(self, *, forced: Optional[bool] = False): @@ -175,7 +141,9 @@ async def heartbeat(self, *, forced: Optional[bool] = False): start = perf_counter_ns() - await self.wait_for(opcode=OpCode.HEARTBEAT_ACK, timeout=self.client.heartbeat_interval) + await self.wait_for( + opcode=OpCode.HEARTBEAT_ACK, timeout=self.client.heartbeat_interval + ) end = perf_counter_ns() @@ -209,6 +177,7 @@ async def handle(self, message: DiscordWSMessage): else: event.future.set_result(event) + class GatewayRateLimiter: def __init__(self): @@ -285,6 +254,7 @@ def __init__( self.http: HTTPClient = http self.rate_limiter: GatewayRateLimiter = GatewayRateLimiter() + self.event_handler: GatewayEventHandler = GatewayEventHandler(self) self.sequence: Optional[int] = None self.session_id: Optional[str] = None @@ -300,4 +270,6 @@ def latency(self) -> Optional[float]: async def connect(self): url = await self.http.get_gateway() version = self.http.version.value - self.ws = await self.http.ws_connect(url, version=version) + self.ws = await self.http.ws_connect(f"{url}?v={version}&encoding=json&compress=zlib-stream") + async for message in self.ws: + await self.event_handler.handle(message) # type: ignore diff --git a/EpikCord/utils/loose.py b/EpikCord/utils/loose.py index 19d72cc2..93fa150a 100644 --- a/EpikCord/utils/loose.py +++ b/EpikCord/utils/loose.py @@ -1,4 +1,6 @@ +import asyncio from importlib.util import find_spec +from logging import getLogger from typing import Any, Dict _ORJSON = find_spec("orjson") @@ -6,10 +8,12 @@ if _ORJSON: import orjson as json else: - import json + import json # type: ignore import aiohttp +logger = getLogger("EpikCord.utils") + def clear_none_values(d: dict): """ @@ -56,3 +60,24 @@ def wrapper(*args, **kwargs): return instance return wrapper + + +def cancel_tasks(loop) -> None: + tasks = {t for t in asyncio.all_tasks(loop=loop) if not t.done()} + + if not tasks: + return + + for task in tasks: + task.cancel() + logger.debug(f"Cancelled {len(tasks)} tasks") + loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True)) + + +def cleanup_loop(loop) -> None: + try: + cancel_tasks(loop) + logger.debug("Shutting down async generators.") + loop.run_until_complete(loop.shutdown_asyncgens()) + finally: + loop.close() diff --git a/EpikCord/utils/types.py b/EpikCord/utils/types.py index bc53d269..46982aa4 100644 --- a/EpikCord/utils/types.py +++ b/EpikCord/utils/types.py @@ -1,4 +1,38 @@ +from ..presence import UpdatePresenceData + + from enum import IntEnum -from typing import Any, Callable, Coroutine +from typing import Any, Callable, Coroutine, TypedDict +from typing_extensions import NotRequired, Literal + +from discord_typings import IdentifyConnectionProperties AsyncFunction = Callable[..., Coroutine[Any, Any, Any]] + +class OpCode(IntEnum): + DISPATCH = 0 + HEARTBEAT = 1 + IDENTIFY = 2 + STATUS_UPDATE = 3 + VOICE_STATE_UPDATE = 4 + VOICE_SERVER_PING = 5 + RESUME = 6 + RECONNECT = 7 + REQUEST_GUILD_MEMBERS = 8 + INVALID_SESSION = 9 + HELLO = 10 + HEARTBEAT_ACK = 11 + + +class IdentifyData(TypedDict): + token: str + intents: int + properties: IdentifyConnectionProperties + compress: bool + large_threshold: int + presence: NotRequired[UpdatePresenceData] + + +class IdentifyCommand(TypedDict): + op: Literal[OpCode.IDENTIFY] + d: IdentifyData From 05ec2ce2cd53ee92567d9d9895767d469a358093 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 16:20:38 +0100 Subject: [PATCH 061/335] :rotating_light: Fix mypy errors --- EpikCord/client/websocket.py | 2 +- EpikCord/ext/tasks.py | 6 +++--- EpikCord/file.py | 4 ++-- EpikCord/utils/loose.py | 12 ++++++++---- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 4b0dad8c..a3594818 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -255,7 +255,7 @@ async def receive(self, *args, **kwargs): message = self.inflator.decompress(self.buffer) message = message.decode("utf-8") - self.buffer: bytearray = bytearray() + self.buffer = bytearray() return DiscordWSMessage( data=message, type=ws_message.type, extra=ws_message.extra diff --git a/EpikCord/ext/tasks.py b/EpikCord/ext/tasks.py index 20987bb6..57c395aa 100644 --- a/EpikCord/ext/tasks.py +++ b/EpikCord/ext/tasks.py @@ -2,12 +2,12 @@ import typing from datetime import timedelta -from ..utils import Coroutine +from typing import Callable, Coroutine class Task: - def __init__(self, wrapped_func: Coroutine, duration: float, max_runs: int): - self.wrapped_func: Coroutine = wrapped_func + def __init__(self, wrapped_func: Callable[..., Coroutine], duration: float, max_runs: int): + self.wrapped_func: Callable[..., Coroutine] = wrapped_func self.duration: float = duration self.max_runs: int = max_runs self.runs: int = 0 diff --git a/EpikCord/file.py b/EpikCord/file.py index 62f61e9b..e4072e62 100644 --- a/EpikCord/file.py +++ b/EpikCord/file.py @@ -32,7 +32,7 @@ def __init__( self.contents: io.IOBase = contents self.mime_type: Optional[str] = mime_type or mimetypes.guess_type(filename)[0] if spoiler: - self.filename: str = f"SPOILER_{filename}" + self.filename = f"SPOILER_{filename}" else: - self.filename: str = filename + self.filename = filename self.description: Optional[str] = description diff --git a/EpikCord/utils/loose.py b/EpikCord/utils/loose.py index 19d72cc2..312935f7 100644 --- a/EpikCord/utils/loose.py +++ b/EpikCord/utils/loose.py @@ -1,14 +1,18 @@ +import aiohttp from importlib.util import find_spec from typing import Any, Dict +from types import ModuleType _ORJSON = find_spec("orjson") +json: ModuleType if _ORJSON: - import orjson as json -else: - import json + import orjson + json = orjson -import aiohttp +else: + import json as _json + json = _json def clear_none_values(d: dict): From 57437eb4b2ace94eacbdd00abe5e8fbe408a0975 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sat, 28 Jan 2023 15:23:58 +0000 Subject: [PATCH 062/335] Move around code for smaller size of main content files --- EpikCord/client/http.py | 150 +----------------- ...uckets.py => rate_limit_handling_tools.py} | 107 ++++++++++++- EpikCord/client/websocket.py | 43 ++--- EpikCord/utils/loose.py | 46 +++++- EpikCord/utils/types.py | 10 ++ 5 files changed, 181 insertions(+), 175 deletions(-) rename EpikCord/client/{buckets.py => rate_limit_handling_tools.py} (63%) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 496eae58..9baabb78 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -3,10 +3,9 @@ import asyncio from enum import IntEnum from logging import getLogger -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type, TypedDict, Union +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type, Union import aiohttp -from typing_extensions import NotRequired from .. import __version__ from ..exceptions import ( @@ -18,8 +17,8 @@ Unauthorized, ) from ..file import File -from ..utils import clean_url, clear_none_values, extract_content, json_serialize -from .buckets import Bucket, MockBucket, TopLevelBucket +from ..utils import clean_url, extract_content, json_serialize, log_request, add_file +from .rate_limit_handling_tools import Bucket, MockBucket, TopLevelBucket, MajorParameters, Route from .websocket import GatewayWebSocket if TYPE_CHECKING: @@ -34,103 +33,6 @@ class APIVersion(IntEnum): NINE = 9 TEN = 10 - -class SendingAttachmentData(TypedDict): - """The data used to send an attachment.""" - - id: int - filename: str - description: NotRequired[str] - ephemeral: NotRequired[bool] - - -class MajorParameters: - def __init__( - self, - channel_id: Optional[int] = None, - guild_id: Optional[int] = None, - webhook_id: Optional[int] = None, - webhook_token: Optional[str] = None, - ): - """A class to group up all the major parameters of a route. - - Parameters - ---------- - channel_id: Optional[int] - The channel id in the route. - guild_id: Optional[int] - The guild id in the route. - webhook_id: Optional[int] - The webhook id in the route. - webhook_token: Optional[str] - The webhook token in the route. - - Attributes - ---------- - channel_id: Optional[int] - The channel id in the route. - guild_id: Optional[int] - The guild id in the route. - webhook_id: Optional[int] - The webhook id in the route. - webhook_token: Optional[str] - The webhook token in the route. - major_parameters: Dict[str, Union[int, str]] - The major parameters compiled into a single dictionary. - """ - self.channel_id: Optional[int] = channel_id - self.guild_id: Optional[int] = guild_id - self.webhook_id: Optional[int] = webhook_id - self.webhook_token: Optional[str] = webhook_token - self.major_parameters: Dict[str, Union[int, str]] = clear_none_values( - { - "channel_id": self.channel_id, - "guild_id": self.guild_id, - "webhook_id": self.webhook_id, - "webhook_token": self.webhook_token, - } - ) - - def __eq__(self, other: Any): - if isinstance(other, MajorParameters): - return self.major_parameters == other.major_parameters - return False - - -class Route: - """Represents a HTTP route.""" - - def __init__( - self, - method: str, - url: str, - *, - major_parameters: MajorParameters = MajorParameters(), - ): - """ - Parameters - ---------- - method: str - The method of the route. - url: str - The url of the route. - major_parameters: MajorParameters - The major parameters of the route. - - Attributes - ---------- - method: str - The method of the route. - url: str - The url of the route. - major_parameters: MajorParameters - The major parameters of the route. - """ - self.method: str = method - self.url: str = url - self.major_parameters: MajorParameters = major_parameters - - class HTTPClient: """The HTTPClient used to make requests to the Discord API.""" @@ -243,7 +145,7 @@ async def request( data = await extract_content(response) - await self.log_request(response, data) + await log_request(response, data) if response.status == 429: await self.handle_ratelimit(data, bucket) @@ -323,48 +225,6 @@ async def handle_ratelimit( self.global_event.set() bucket.set() - @staticmethod - async def log_request(res, body: Optional[dict] = None): - """Logs information about the request.""" - messages = [ - f"Sent a {res.method} to {res.url} " f"and got a {res.status} response. ", - f"Content-Type: {res.headers['Content-Type']} ", - ] - - if body: - messages.append(f"Sent body: {body} ") - - if h := dict(res.request_info.headers): - messages.append(f"Sent headers: {h} ") - - if h := dict(res.headers): - messages.append(f"Received headers: {h} ") - - try: - messages.append(f"Received body: {await res.json()} ") - - finally: - logger.debug("".join(messages)) - - def add_file( - self, form: aiohttp.FormData, file: File, i: int - ) -> SendingAttachmentData: - form.add_field( - f"files[{i}]", - file.contents, - filename=file.filename, - content_type=file.mime_type, - ) - - attachment: SendingAttachmentData = { - "filename": file.filename, - "id": i, - } - if file.description: - attachment["description"] = file.description - - return attachment - def setup_request_kwargs( self, kwargs, *, files: Optional[List[File]] = None, json: Optional[Dict] = None ) -> Optional[Dict]: @@ -394,7 +254,7 @@ def setup_request_kwargs( if files: for i, file in enumerate(files): - attachment = self.add_file(form, file, i) + attachment = add_file(form, file, i) json["attachments"].append(attachment) form.add_field("payload_json", self.session.json_serialize(json)) diff --git a/EpikCord/client/buckets.py b/EpikCord/client/rate_limit_handling_tools.py similarity index 63% rename from EpikCord/client/buckets.py rename to EpikCord/client/rate_limit_handling_tools.py index 9fdff651..5cd85426 100644 --- a/EpikCord/client/buckets.py +++ b/EpikCord/client/rate_limit_handling_tools.py @@ -1,13 +1,33 @@ from __future__ import annotations import asyncio +from datetime import timedelta from logging import getLogger from typing import Any, Dict, Optional, Union from ..utils import clear_none_values - +from ..ext import tasks logger = getLogger("EpikCord.http") +class GatewayRateLimiter: + def __init__(self): + + self.event = asyncio.Event() + self.event.set() + + self.remaining = 120 + self.limit = 120 + + @tasks.task(duration=timedelta(seconds=60)) + async def reset(self): + self.remaining = self.limit + self.event.set() + + async def tick(self): + await self.event.wait() + self.remaining -= 1 + if self.remaining == 0: + self.event.clear() class MockBucket: """A mock bucket that does nothing.""" @@ -189,3 +209,88 @@ async def handle_exhaustion(self, retry_after: int): logger.info(f"Bucket {str(self)} exhausted, waiting {retry_after} seconds.") await asyncio.sleep(retry_after) self.set() + +class MajorParameters: + def __init__( + self, + channel_id: Optional[int] = None, + guild_id: Optional[int] = None, + webhook_id: Optional[int] = None, + webhook_token: Optional[str] = None, + ): + """A class to group up all the major parameters of a route. + + Parameters + ---------- + channel_id: Optional[int] + The channel id in the route. + guild_id: Optional[int] + The guild id in the route. + webhook_id: Optional[int] + The webhook id in the route. + webhook_token: Optional[str] + The webhook token in the route. + + Attributes + ---------- + channel_id: Optional[int] + The channel id in the route. + guild_id: Optional[int] + The guild id in the route. + webhook_id: Optional[int] + The webhook id in the route. + webhook_token: Optional[str] + The webhook token in the route. + major_parameters: Dict[str, Union[int, str]] + The major parameters compiled into a single dictionary. + """ + self.channel_id: Optional[int] = channel_id + self.guild_id: Optional[int] = guild_id + self.webhook_id: Optional[int] = webhook_id + self.webhook_token: Optional[str] = webhook_token + self.major_parameters: Dict[str, Union[int, str]] = clear_none_values( + { + "channel_id": self.channel_id, + "guild_id": self.guild_id, + "webhook_id": self.webhook_id, + "webhook_token": self.webhook_token, + } + ) + + def __eq__(self, other: Any): + if isinstance(other, MajorParameters): + return self.major_parameters == other.major_parameters + return False + +class Route: + """Represents a HTTP route.""" + + def __init__( + self, + method: str, + url: str, + *, + major_parameters: MajorParameters = MajorParameters(), + ): + """ + Parameters + ---------- + method: str + The method of the route. + url: str + The url of the route. + major_parameters: MajorParameters + The major parameters of the route. + + Attributes + ---------- + method: str + The method of the route. + url: str + The url of the route. + major_parameters: MajorParameters + The major parameters of the route. + """ + self.method: str = method + self.url: str = url + self.major_parameters: MajorParameters = major_parameters \ No newline at end of file diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 3a8b9c1b..4fab3cae 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -5,12 +5,13 @@ from sys import platform from time import perf_counter_ns import zlib -from datetime import timedelta from discord_typings import HelloData from importlib.util import find_spec +from functools import partial from typing import ( Any, DefaultDict, + Dict, Optional, TYPE_CHECKING, List, @@ -19,8 +20,7 @@ from logging import getLogger import aiohttp - -from ..ext import tasks +from .rate_limit_handling_tools import GatewayRateLimiter from ..flags import Intents from ..presence import Presence from ..utils import AsyncFunction, OpCode, IdentifyCommand @@ -59,6 +59,10 @@ class GatewayEventHandler: def __init__(self, client: WebSocketClient): self.client = client self.wait_for_events: DefaultDict[Union[str, int], List] = defaultdict(list) + self.event_mapping: Dict[OpCode, AsyncFunction] = { + OpCode.HELLO: self.hello, + OpCode.HEARTBEAT: partial(self.heartbeat, forced=True), + } def wait_for( self, @@ -73,7 +77,7 @@ def wait_for( elif name and opcode: raise ValueError("Only name or opcode can be provided.") - event = WaitForEvent(name=name, timeout=timeout, check=check) + event = WaitForEvent(name=name, opcode=opcode, timeout=timeout, check=check) if name: self.wait_for_events[name].append(event) @@ -87,6 +91,7 @@ async def send_json(self, payload: Any): logger.error("Tried to send a payload without a websocket connection.") return await self.client.rate_limiter.tick() + logger.debug("Sending %s to Gateway", payload) await self.client.ws.send_json(payload) async def identify(self): @@ -155,7 +160,6 @@ async def handle(self, message: DiscordWSMessage): except json.JSONDecodeError: logger.error("Failed to decode message: %s", message.data) return - if event["op"] in self.wait_for_events: for event in self.wait_for_events[event["op"]]: if event.check: @@ -166,7 +170,8 @@ async def handle(self, message: DiscordWSMessage): event.future.set_exception(exception) else: event.future.set_result(event) - elif event["t"].lower() in self.wait_for_events: + + elif event.get("t") and event["t"].lower() in self.wait_for_events: for event in self.wait_for_events[event["t"].lower()]: if event.check: try: @@ -177,28 +182,8 @@ async def handle(self, message: DiscordWSMessage): else: event.future.set_result(event) - -class GatewayRateLimiter: - def __init__(self): - - self.event = asyncio.Event() - self.event.set() - - self.remaining = 120 - self.limit = 120 - self.reset.run() - - @tasks.task(duration=timedelta(seconds=60)) - async def reset(self): - self.remaining = self.limit - self.event.set() - - async def tick(self): - await self.event.wait() - self.remaining -= 1 - if self.remaining == 0: - self.event.clear() - + if event["op"] != OpCode.DISPATCH: + await self.event_mapping[event["op"]](event["d"]) class DiscordWSMessage: def __init__(self, *, data, type, extra): @@ -270,6 +255,8 @@ def latency(self) -> Optional[float]: async def connect(self): url = await self.http.get_gateway() version = self.http.version.value + asyncio.create_task(self.rate_limiter.reset.start()) self.ws = await self.http.ws_connect(f"{url}?v={version}&encoding=json&compress=zlib-stream") async for message in self.ws: + logger.debug("Received message: %s", message.json()) await self.event_handler.handle(message) # type: ignore diff --git a/EpikCord/utils/loose.py b/EpikCord/utils/loose.py index 93fa150a..ceea555e 100644 --- a/EpikCord/utils/loose.py +++ b/EpikCord/utils/loose.py @@ -1,7 +1,10 @@ import asyncio from importlib.util import find_spec from logging import getLogger -from typing import Any, Dict +from typing import Any, Dict, Optional + +from ..file import File +from . import SendingAttachmentData _ORJSON = find_spec("orjson") @@ -81,3 +84,44 @@ def cleanup_loop(loop) -> None: loop.run_until_complete(loop.shutdown_asyncgens()) finally: loop.close() + +async def log_request(res, body: Optional[dict] = None): + """Logs information about the request.""" + messages = [ + f"Sent a {res.method} to {res.url} " f"and got a {res.status} response. ", + f"Content-Type: {res.headers['Content-Type']} ", + ] + + if body: + messages.append(f"Sent body: {body} ") + + if h := dict(res.request_info.headers): + messages.append(f"Sent headers: {h} ") + + if h := dict(res.headers): + messages.append(f"Received headers: {h} ") + + try: + messages.append(f"Received body: {await res.json()} ") + + finally: + logger.debug("".join(messages)) + +def add_file( + form: aiohttp.FormData, file: File, i: int +) -> SendingAttachmentData: + form.add_field( + f"files[{i}]", + file.contents, + filename=file.filename, + content_type=file.mime_type, + ) + + attachment: SendingAttachmentData = { + "filename": file.filename, + "id": i, + } + if file.description: + attachment["description"] = file.description + + return attachment diff --git a/EpikCord/utils/types.py b/EpikCord/utils/types.py index 46982aa4..b15e1de7 100644 --- a/EpikCord/utils/types.py +++ b/EpikCord/utils/types.py @@ -10,6 +10,7 @@ AsyncFunction = Callable[..., Coroutine[Any, Any, Any]] class OpCode(IntEnum): + """The opcodes used in the Discord Gateway.""" DISPATCH = 0 HEARTBEAT = 1 IDENTIFY = 2 @@ -25,6 +26,7 @@ class OpCode(IntEnum): class IdentifyData(TypedDict): + """The data used in the identify payload.""" token: str intents: int properties: IdentifyConnectionProperties @@ -34,5 +36,13 @@ class IdentifyData(TypedDict): class IdentifyCommand(TypedDict): + """The data used to identify with the gateway.""" op: Literal[OpCode.IDENTIFY] d: IdentifyData + +class SendingAttachmentData(TypedDict): + """The data used to send an attachment.""" + id: int + filename: str + description: NotRequired[str] + ephemeral: NotRequired[bool] From b76b3e62eb2f4930e398c34af8e3aa815e17a188 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sat, 28 Jan 2023 15:29:05 +0000 Subject: [PATCH 063/335] Resolve circular imports --- EpikCord/utils/loose.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/EpikCord/utils/loose.py b/EpikCord/utils/loose.py index 025c1402..72c380ad 100644 --- a/EpikCord/utils/loose.py +++ b/EpikCord/utils/loose.py @@ -1,12 +1,13 @@ +from __future__ import annotations + import asyncio from importlib.util import find_spec from logging import getLogger -from typing import Any, Dict, Optional +from typing import TYPE_CHECKING, Any, Dict, Optional from types import ModuleType import aiohttp from ..file import File -from . import SendingAttachmentData _ORJSON = find_spec("orjson") json: ModuleType @@ -19,6 +20,9 @@ import json as _json json = _json +if TYPE_CHECKING: + from . import SendingAttachmentData + logger = getLogger("EpikCord.utils") From 49a95470934abc98546cd1dbfaa909d5f750fc44 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sat, 28 Jan 2023 16:04:59 +0000 Subject: [PATCH 064/335] Current state --- EpikCord/client/websocket.py | 38 ++++++++++++++++-------------- EpikCord/utils/types.py | 45 ++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 17 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 88eab326..1b3fc708 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -62,8 +62,13 @@ def __init__(self, client: WebSocketClient): self.event_mapping: Dict[OpCode, AsyncFunction] = { OpCode.HELLO: self.hello, OpCode.HEARTBEAT: partial(self.heartbeat, forced=True), + OpCode.HEARTBEAT_ACK: self.heartbeat_ack, } + async def heartbeat_ack(self, data: Any): + """Handle the heartbeat ack event. OpCode 11.""" + logger.debug("Received heartbeat ack from Gateway") + def wait_for( self, *, @@ -160,27 +165,23 @@ async def handle(self, message: DiscordWSMessage): except json.JSONDecodeError: logger.error("Failed to decode message: %s", message.data) return - if event["op"] in self.wait_for_events: - for event in self.wait_for_events[event["op"]]: - if event.check: - try: - if await event.check(event): - event.future.set_result(event) - except Exception as exception: - event.future.set_exception(exception) - else: - event.future.set_result(event) + value: Optional[Union[str, int]] = None + + if event["op"] in self.wait_for_events: + value = event["op"] + elif event.get("t") and event["t"].lower() in self.wait_for_events: - for event in self.wait_for_events[event["t"].lower()]: - if event.check: + value = event["t"].lower() + + if value: + for wait_for_event in self.wait_for_events[value]: + if wait_for_event.check: try: - if await event.check(event): - event.future.set_result(event) + await wait_for_event.check(event) except Exception as exception: - event.future.set_exception(exception) - else: - event.future.set_result(event) + wait_for_event.future.set_exception(exception) + wait_for_event.future.set_result(event) if event["op"] != OpCode.DISPATCH: await self.event_mapping[event["op"]](event["d"]) @@ -260,3 +261,6 @@ async def connect(self): async for message in self.ws: logger.debug("Received message: %s", message.json()) await self.event_handler.handle(message) # type: ignore + + async def handle_close(self): + ... \ No newline at end of file diff --git a/EpikCord/utils/types.py b/EpikCord/utils/types.py index b15e1de7..594e53e0 100644 --- a/EpikCord/utils/types.py +++ b/EpikCord/utils/types.py @@ -24,6 +24,51 @@ class OpCode(IntEnum): HELLO = 10 HEARTBEAT_ACK = 11 +class GatewayCloseCode(IntEnum): + """The close codes used in the Discord Gateway.""" + UNKNOWN_ERROR = 4000 + UNKNOWN_OPCODE = 4001 + DECODE_ERROR = 4002 + NOT_AUTHENTICATED = 4003 + AUTHENTICATION_FAILED = 4004 + ALREADY_AUTHENTICATED = 4005 + INVALID_SEQUENCE = 4007 + RATE_LIMITED = 4008 + SESSION_TIMED_OUT = 4009 + INVALID_SHARD = 4010 + SHARDING_REQUIRED = 4011 + INVALID_API_VERSION = 4012 + INVALID_INTENTS = 4013 + DISALLOWED_INTENTS = 4014 + +class VoiceOpCode(IntEnum): + """The opcodes used in the Discord Voice WebSocket connection.""" + IDENTIFY = 0 + SELECT_PROTOCOL = 1 + READY = 2 + HEARTBEAT = 3 + SESSION_DESCRIPTION = 4 + SPEAKING = 5 + HEARTBEAT_ACK = 6 + RESUME = 7 + HELLO = 8 + RESUMED = 9 + CLIENT_DISCONNECT = 13 + +class VoiceCloseCode(IntEnum): + """The close codes used in the Discord Voice WebSocket connection.""" + UNKNOWN_OPCODE = 4001 + FAILED_TO_DECODE_PAYLOAD = 4002 + NOT_AUTHENTICATED = 4003 + AUTHENTICATION_FAILED = 4004 + ALREADY_AUTHENTICATED = 4005 + SESSION_NO_LONGER_VALID = 4006 + SESSION_TIMEOUT = 4009 + SERVER_NOT_FOUND = 4011 + UNKNOWN_PROTOCOL = 4012 + DISCONNECTED = 4014 + VOICE_SERVER_CRASHED = 4015 + UNKNOWN_ENCRYPTION_MODE = 4016 class IdentifyData(TypedDict): """The data used in the identify payload.""" From d1acfa0557eee3d2bff15a5c8a43d9e5dd6de297 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 16:41:43 +0100 Subject: [PATCH 065/335] :construction_worker: Split Nox actions --- .github/workflows/ci.yml | 5 +++-- noxfile.py | 6 ++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e393bfb..34d7ff8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,9 +10,10 @@ jobs: matrix: python: [ '3.8', '3.9', '3.10' ] os: [ ubuntu, windows, macos ] + nox_action: ["install", "lint", "myright"] fail-fast: false - name: Nox - py ${{ matrix.python }} - ${{ matrix.os }} + name: Nox - py ${{ matrix.python }} - ${{ matrix.os }} - ${{ matrix.nox_action }} steps: - uses: actions/checkout@v2 @@ -29,4 +30,4 @@ jobs: run: pip install nox git+https://github.com/Bluenix2/discord-typings - name: Run nox - run: nox -s lint myright + run: nox -s ${{ matrix.nox_action }} diff --git a/noxfile.py b/noxfile.py index f4f30a93..be53dead 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,6 +1,12 @@ import nox +@nox.session(name="install") +def installation(session: nox.Session): + session.install("-r", "requirements.txt") + session.install(".") + + @nox.session(name="format") def format_(session: nox.Session): session.install("black", "isort", ".", "aiohttp", "orjson") From a576785e6bc347a92568446239f990807b0151e9 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 16:54:07 +0100 Subject: [PATCH 066/335] :construction_worker: Typo --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34d7ff8e..1f75652b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: matrix: python: [ '3.8', '3.9', '3.10' ] os: [ ubuntu, windows, macos ] - nox_action: ["install", "lint", "myright"] + nox_action: ["install", "lint", "pyright"] fail-fast: false name: Nox - py ${{ matrix.python }} - ${{ matrix.os }} - ${{ matrix.nox_action }} From 86336467def3a859b3c2cba844c7bda49f799f92 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 17:10:43 +0100 Subject: [PATCH 067/335] :bug: Fix the noxfile --- noxfile.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index be53dead..57500e6b 100644 --- a/noxfile.py +++ b/noxfile.py @@ -9,7 +9,7 @@ def installation(session: nox.Session): @nox.session(name="format") def format_(session: nox.Session): - session.install("black", "isort", ".", "aiohttp", "orjson") + session.install("black", "isort") session.run("isort", "EpikCord") session.run("black", "EpikCord") @@ -23,4 +23,5 @@ def lint(session: nox.Session): @nox.session def pyright(session: nox.Session): session.install("pyright") + session.install("-r", "requirements") session.run("pyright", "EpikCord") From 45b108ae95dabc14f8b8f3853be8ff3de90bfc90 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 17:11:02 +0100 Subject: [PATCH 068/335] :construction_worker: Enable black CI on every branchs --- .github/workflows/black.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml index 2f4f3b9c..3e130511 100644 --- a/.github/workflows/black.yml +++ b/.github/workflows/black.yml @@ -3,7 +3,6 @@ name: Black on: workflow_dispatch: push: - branches: [ master ] jobs: deploy: From e7df72c2f79b9e6bd7a0e2a96f29648a657ab6b8 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 17:15:42 +0100 Subject: [PATCH 069/335] :twisted_rightwards_arrows: Merge conflicts from rewrite --- EpikCord/__init__.py | 4 +-- EpikCord/client/client.py | 9 +++--- EpikCord/client/http.py | 11 +++++-- EpikCord/client/rate_limit_handling_tools.py | 9 ++++-- EpikCord/client/websocket.py | 31 +++++++++----------- EpikCord/ext/tasks.py | 5 ++-- EpikCord/utils/types.py | 13 +++++--- 7 files changed, 49 insertions(+), 33 deletions(-) diff --git a/EpikCord/__init__.py b/EpikCord/__init__.py index 9cf56c58..8ac6c73b 100644 --- a/EpikCord/__init__.py +++ b/EpikCord/__init__.py @@ -1,7 +1,7 @@ __version__ = "0.6.0" from .client import * -from .flags import * -from .file import * from .exceptions import * from .ext import * +from .file import * +from .flags import * diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index 2ac6778e..647881d2 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -3,10 +3,9 @@ from ..flags import Intents from ..presence import Presence -from ..utils import singleton -from .websocket import WebSocketClient +from ..utils import cleanup_loop, singleton from .http import APIVersion, HTTPClient -from ..utils import cleanup_loop +from .websocket import WebSocketClient @singleton @@ -45,7 +44,9 @@ def __init__( http: HTTPClient The HTTP client used to interact with the Discord API. """ - super().__init__(token, intents, presence=presence, http=HTTPClient(token, version=version)) + super().__init__( + token, intents, presence=presence, http=HTTPClient(token, version=version) + ) def login(self): loop = asyncio.get_event_loop() diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 9baabb78..fb80eb77 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -17,8 +17,14 @@ Unauthorized, ) from ..file import File -from ..utils import clean_url, extract_content, json_serialize, log_request, add_file -from .rate_limit_handling_tools import Bucket, MockBucket, TopLevelBucket, MajorParameters, Route +from ..utils import add_file, clean_url, extract_content, json_serialize, log_request +from .rate_limit_handling_tools import ( + Bucket, + MajorParameters, + MockBucket, + Route, + TopLevelBucket, +) from .websocket import GatewayWebSocket if TYPE_CHECKING: @@ -33,6 +39,7 @@ class APIVersion(IntEnum): NINE = 9 TEN = 10 + class HTTPClient: """The HTTPClient used to make requests to the Discord API.""" diff --git a/EpikCord/client/rate_limit_handling_tools.py b/EpikCord/client/rate_limit_handling_tools.py index 5cd85426..a3b08bac 100644 --- a/EpikCord/client/rate_limit_handling_tools.py +++ b/EpikCord/client/rate_limit_handling_tools.py @@ -5,10 +5,12 @@ from logging import getLogger from typing import Any, Dict, Optional, Union -from ..utils import clear_none_values from ..ext import tasks +from ..utils import clear_none_values + logger = getLogger("EpikCord.http") + class GatewayRateLimiter: def __init__(self): @@ -29,6 +31,7 @@ async def tick(self): if self.remaining == 0: self.event.clear() + class MockBucket: """A mock bucket that does nothing.""" @@ -210,6 +213,7 @@ async def handle_exhaustion(self, retry_after: int): await asyncio.sleep(retry_after) self.set() + class MajorParameters: def __init__( self, @@ -262,6 +266,7 @@ def __eq__(self, other: Any): return self.major_parameters == other.major_parameters return False + class Route: """Represents a HTTP route.""" @@ -293,4 +298,4 @@ def __init__( """ self.method: str = method self.url: str = url - self.major_parameters: MajorParameters = major_parameters \ No newline at end of file + self.major_parameters: MajorParameters = major_parameters diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 1b3fc708..fd8758dd 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -1,29 +1,22 @@ from __future__ import annotations import asyncio -from collections import defaultdict -from sys import platform -from time import perf_counter_ns import zlib -from discord_typings import HelloData -from importlib.util import find_spec +from collections import defaultdict from functools import partial -from typing import ( - Any, - DefaultDict, - Dict, - Optional, - TYPE_CHECKING, - List, - Union, -) +from importlib.util import find_spec from logging import getLogger +from sys import platform +from time import perf_counter_ns +from typing import TYPE_CHECKING, Any, DefaultDict, Dict, List, Optional, Union import aiohttp -from .rate_limit_handling_tools import GatewayRateLimiter +from discord_typings import HelloData + from ..flags import Intents from ..presence import Presence -from ..utils import AsyncFunction, OpCode, IdentifyCommand +from ..utils import AsyncFunction, IdentifyCommand, OpCode +from .rate_limit_handling_tools import GatewayRateLimiter _ORJSON = find_spec("orjson") @@ -39,6 +32,7 @@ logger = getLogger("EpikCord.websocket") + class WaitForEvent: def __init__( self, @@ -186,6 +180,7 @@ async def handle(self, message: DiscordWSMessage): if event["op"] != OpCode.DISPATCH: await self.event_mapping[event["op"]](event["d"]) + class DiscordWSMessage: def __init__(self, *, data, type, extra): self.data = data @@ -257,7 +252,9 @@ async def connect(self): url = await self.http.get_gateway() version = self.http.version.value asyncio.create_task(self.rate_limiter.reset.start()) - self.ws = await self.http.ws_connect(f"{url}?v={version}&encoding=json&compress=zlib-stream") + self.ws = await self.http.ws_connect( + f"{url}?v={version}&encoding=json&compress=zlib-stream" + ) async for message in self.ws: logger.debug("Received message: %s", message.json()) await self.event_handler.handle(message) # type: ignore diff --git a/EpikCord/ext/tasks.py b/EpikCord/ext/tasks.py index 57c395aa..ea8f3045 100644 --- a/EpikCord/ext/tasks.py +++ b/EpikCord/ext/tasks.py @@ -1,12 +1,13 @@ import asyncio import typing from datetime import timedelta - from typing import Callable, Coroutine class Task: - def __init__(self, wrapped_func: Callable[..., Coroutine], duration: float, max_runs: int): + def __init__( + self, wrapped_func: Callable[..., Coroutine], duration: float, max_runs: int + ): self.wrapped_func: Callable[..., Coroutine] = wrapped_func self.duration: float = duration self.max_runs: int = max_runs diff --git a/EpikCord/utils/types.py b/EpikCord/utils/types.py index 594e53e0..11d75696 100644 --- a/EpikCord/utils/types.py +++ b/EpikCord/utils/types.py @@ -1,16 +1,17 @@ -from ..presence import UpdatePresenceData - - from enum import IntEnum from typing import Any, Callable, Coroutine, TypedDict -from typing_extensions import NotRequired, Literal from discord_typings import IdentifyConnectionProperties +from typing_extensions import Literal, NotRequired + +from ..presence import UpdatePresenceData AsyncFunction = Callable[..., Coroutine[Any, Any, Any]] + class OpCode(IntEnum): """The opcodes used in the Discord Gateway.""" + DISPATCH = 0 HEARTBEAT = 1 IDENTIFY = 2 @@ -72,6 +73,7 @@ class VoiceCloseCode(IntEnum): class IdentifyData(TypedDict): """The data used in the identify payload.""" + token: str intents: int properties: IdentifyConnectionProperties @@ -82,11 +84,14 @@ class IdentifyData(TypedDict): class IdentifyCommand(TypedDict): """The data used to identify with the gateway.""" + op: Literal[OpCode.IDENTIFY] d: IdentifyData + class SendingAttachmentData(TypedDict): """The data used to send an attachment.""" + id: int filename: str description: NotRequired[str] From d187d53f8f08bda432b16274b63895111a9b95dd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 28 Jan 2023 16:17:58 +0000 Subject: [PATCH 070/335] :art: Format code automatically --- EpikCord/client/websocket.py | 4 ++-- EpikCord/utils/loose.py | 11 +++++++---- EpikCord/utils/types.py | 7 +++++++ 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index fd8758dd..7e51272e 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -164,7 +164,7 @@ async def handle(self, message: DiscordWSMessage): if event["op"] in self.wait_for_events: value = event["op"] - + elif event.get("t") and event["t"].lower() in self.wait_for_events: value = event["t"].lower() @@ -260,4 +260,4 @@ async def connect(self): await self.event_handler.handle(message) # type: ignore async def handle_close(self): - ... \ No newline at end of file + ... diff --git a/EpikCord/utils/loose.py b/EpikCord/utils/loose.py index 72c380ad..cc7a78f3 100644 --- a/EpikCord/utils/loose.py +++ b/EpikCord/utils/loose.py @@ -3,10 +3,11 @@ import asyncio from importlib.util import find_spec from logging import getLogger -from typing import TYPE_CHECKING, Any, Dict, Optional from types import ModuleType +from typing import TYPE_CHECKING, Any, Dict, Optional import aiohttp + from ..file import File _ORJSON = find_spec("orjson") @@ -14,10 +15,12 @@ if _ORJSON: import orjson + json = orjson else: import json as _json + json = _json if TYPE_CHECKING: @@ -93,6 +96,7 @@ def cleanup_loop(loop) -> None: finally: loop.close() + async def log_request(res, body: Optional[dict] = None): """Logs information about the request.""" messages = [ @@ -115,9 +119,8 @@ async def log_request(res, body: Optional[dict] = None): finally: logger.debug("".join(messages)) -def add_file( - form: aiohttp.FormData, file: File, i: int -) -> SendingAttachmentData: + +def add_file(form: aiohttp.FormData, file: File, i: int) -> SendingAttachmentData: form.add_field( f"files[{i}]", file.contents, diff --git a/EpikCord/utils/types.py b/EpikCord/utils/types.py index 11d75696..47147071 100644 --- a/EpikCord/utils/types.py +++ b/EpikCord/utils/types.py @@ -25,8 +25,10 @@ class OpCode(IntEnum): HELLO = 10 HEARTBEAT_ACK = 11 + class GatewayCloseCode(IntEnum): """The close codes used in the Discord Gateway.""" + UNKNOWN_ERROR = 4000 UNKNOWN_OPCODE = 4001 DECODE_ERROR = 4002 @@ -42,8 +44,10 @@ class GatewayCloseCode(IntEnum): INVALID_INTENTS = 4013 DISALLOWED_INTENTS = 4014 + class VoiceOpCode(IntEnum): """The opcodes used in the Discord Voice WebSocket connection.""" + IDENTIFY = 0 SELECT_PROTOCOL = 1 READY = 2 @@ -56,8 +60,10 @@ class VoiceOpCode(IntEnum): RESUMED = 9 CLIENT_DISCONNECT = 13 + class VoiceCloseCode(IntEnum): """The close codes used in the Discord Voice WebSocket connection.""" + UNKNOWN_OPCODE = 4001 FAILED_TO_DECODE_PAYLOAD = 4002 NOT_AUTHENTICATED = 4003 @@ -71,6 +77,7 @@ class VoiceCloseCode(IntEnum): VOICE_SERVER_CRASHED = 4015 UNKNOWN_ENCRYPTION_MODE = 4016 + class IdentifyData(TypedDict): """The data used in the identify payload.""" From 9c487753cb751dd94db4e2c452b67db961e4e151 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 17:19:27 +0100 Subject: [PATCH 071/335] :bug: Fixing file path --- noxfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index 57500e6b..d92e77ab 100644 --- a/noxfile.py +++ b/noxfile.py @@ -23,5 +23,5 @@ def lint(session: nox.Session): @nox.session def pyright(session: nox.Session): session.install("pyright") - session.install("-r", "requirements") + session.install("-r", "requirements.txt") session.run("pyright", "EpikCord") From e2b527b9ee493a43619ff98f5fefccb1fb282ca1 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 17:22:19 +0100 Subject: [PATCH 072/335] :wrench: Add config to normalize tools & IDE --- .editorconfig | 10 ++++++++++ .flake8 | 5 +++++ .isort.cfg | 3 +++ pyproject.toml | 6 +++++- 4 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 .editorconfig create mode 100644 .flake8 create mode 100644 .isort.cfg diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..9f56f42b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = false +max_line_length = 80 +tab_width = 4 diff --git a/.flake8 b/.flake8 new file mode 100644 index 00000000..215dcf81 --- /dev/null +++ b/.flake8 @@ -0,0 +1,5 @@ +[flake8] +ignore = E203 + # whitespace before : in slicing. +exclude = .git, .idea, __pycache__, build, venv +max-complexity = 10 diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 00000000..d056ad08 --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,3 @@ +[settings] +profile = black +line_length = 80 diff --git a/pyproject.toml b/pyproject.toml index 7ef85f55..b5208980 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,7 @@ [build-system] requires = ["setuptools"] -build-backend = "setuptools.build_meta" \ No newline at end of file +build-backend = "setuptools.build_meta" + +[tool.black] +line-length = 79 +target-version = ['py38'] From 3f8055278ac6aadd59a0f0a491689b78cf5d3538 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 28 Jan 2023 16:22:50 +0000 Subject: [PATCH 073/335] :art: Format code automatically --- EpikCord/client/client.py | 7 ++-- EpikCord/client/http.py | 37 +++++++++++++++----- EpikCord/client/rate_limit_handling_tools.py | 16 +++++---- EpikCord/client/websocket.py | 12 +++++-- EpikCord/ext/tasks.py | 5 ++- EpikCord/file.py | 4 ++- EpikCord/flags.py | 12 +++++-- EpikCord/presence.py | 21 ++++++++--- EpikCord/utils/loose.py | 7 ++-- 9 files changed, 91 insertions(+), 30 deletions(-) diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index 647881d2..62737227 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -23,7 +23,7 @@ def __init__( intents: Intents, *, version: APIVersion = APIVersion.TEN, - presence: Optional[Presence] = None + presence: Optional[Presence] = None, ): """ Parameters @@ -45,7 +45,10 @@ def __init__( The HTTP client used to interact with the Discord API. """ super().__init__( - token, intents, presence=presence, http=HTTPClient(token, version=version) + token, + intents, + presence=presence, + http=HTTPClient(token, version=version), ) def login(self): diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index fb80eb77..cd869d1d 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -17,7 +17,13 @@ Unauthorized, ) from ..file import File -from ..utils import add_file, clean_url, extract_content, json_serialize, log_request +from ..utils import ( + add_file, + clean_url, + extract_content, + json_serialize, + log_request, +) from .rate_limit_handling_tools import ( Bucket, MajorParameters, @@ -50,7 +56,9 @@ class HTTPClient: 404: NotFound, } - def __init__(self, token: TokenStore, *, version: APIVersion = APIVersion.TEN): + def __init__( + self, token: TokenStore, *, version: APIVersion = APIVersion.TEN + ): """ Parameters ---------- @@ -147,7 +155,8 @@ async def request( "X-RateLimit-Bucket" ): bucket = await self.set_bucket( - response=response, major_parameters=route.major_parameters + response=response, + major_parameters=route.major_parameters, ) data = await extract_content(response) @@ -161,11 +170,15 @@ async def request( int(response.headers["X-RateLimit-Reset-After"]) ) elif not response.ok: - error = self.error_mapping.get(response.status, HTTPException) + error = self.error_mapping.get( + response.status, HTTPException + ) raise error(response, data) elif response.ok: return response - raise TooManyRetries(f"Attempted {method} request to {url} 5 times but failed.") + raise TooManyRetries( + f"Attempted {method} request to {url} 5 times but failed." + ) async def set_bucket( self, @@ -204,14 +217,18 @@ async def set_bucket( bucket = Bucket(bucket_hash=response.headers["X-RateLimit-Bucket"]) if bucket in self.buckets.values(): listed_buckets = list(self.buckets.values()) - self.buckets[bucket_key] = listed_buckets[listed_buckets.index(bucket)] + self.buckets[bucket_key] = listed_buckets[ + listed_buckets.index(bucket) + ] bucket = self.buckets[bucket_key] self.buckets[bucket_key] = bucket return bucket async def handle_ratelimit( - self, data: Dict[str, Any], bucket: Union[Bucket, TopLevelBucket, MockBucket] + self, + data: Dict[str, Any], + bucket: Union[Bucket, TopLevelBucket, MockBucket], ): """Handles the ratelimit for the request. @@ -233,7 +250,11 @@ async def handle_ratelimit( bucket.set() def setup_request_kwargs( - self, kwargs, *, files: Optional[List[File]] = None, json: Optional[Dict] = None + self, + kwargs, + *, + files: Optional[List[File]] = None, + json: Optional[Dict] = None, ) -> Optional[Dict]: """Sets up the request kwargs. diff --git a/EpikCord/client/rate_limit_handling_tools.py b/EpikCord/client/rate_limit_handling_tools.py index a3b08bac..e583cb62 100644 --- a/EpikCord/client/rate_limit_handling_tools.py +++ b/EpikCord/client/rate_limit_handling_tools.py @@ -111,7 +111,9 @@ async def handle_exhaustion(self, retry_after: int): The amount of time to wait before retrying. """ self.clear() - logger.info(f"Bucket {self.hash} exhausted, waiting {retry_after} seconds.") + logger.info( + f"Bucket {self.hash} exhausted, waiting {retry_after} seconds." + ) await asyncio.sleep(retry_after) self.set() @@ -158,7 +160,9 @@ def __init__( self.guild_id: Optional[int] = guild_id self.webhook_id: Optional[int] = webhook_id self.webhook_token: Optional[str] = webhook_token - self.major_parameters: Dict[str, Optional[Union[int, str]]] = clear_none_values( + self.major_parameters: Dict[ + str, Optional[Union[int, str]] + ] = clear_none_values( { "channel_id": channel_id, "guild_id": guild_id, @@ -195,9 +199,7 @@ def __eq__(self, other: Any): return False def __str__(self): - return ( - f"{self.channel_id}:{self.guild_id}:{self.webhook_id}:{self.webhook_token}" - ) + return f"{self.channel_id}:{self.guild_id}:{self.webhook_id}:{self.webhook_token}" async def handle_exhaustion(self, retry_after: int): """ @@ -209,7 +211,9 @@ async def handle_exhaustion(self, retry_after: int): The amount of time to wait before retrying. """ self.clear() - logger.info(f"Bucket {str(self)} exhausted, waiting {retry_after} seconds.") + logger.info( + f"Bucket {str(self)} exhausted, waiting {retry_after} seconds." + ) await asyncio.sleep(retry_after) self.set() diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 7e51272e..a633463a 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -52,7 +52,9 @@ def __init__( class GatewayEventHandler: def __init__(self, client: WebSocketClient): self.client = client - self.wait_for_events: DefaultDict[Union[str, int], List] = defaultdict(list) + self.wait_for_events: DefaultDict[Union[str, int], List] = defaultdict( + list + ) self.event_mapping: Dict[OpCode, AsyncFunction] = { OpCode.HELLO: self.hello, OpCode.HEARTBEAT: partial(self.heartbeat, forced=True), @@ -76,7 +78,9 @@ def wait_for( elif name and opcode: raise ValueError("Only name or opcode can be provided.") - event = WaitForEvent(name=name, opcode=opcode, timeout=timeout, check=check) + event = WaitForEvent( + name=name, opcode=opcode, timeout=timeout, check=check + ) if name: self.wait_for_events[name].append(event) @@ -87,7 +91,9 @@ def wait_for( async def send_json(self, payload: Any): if not self.client.ws: - logger.error("Tried to send a payload without a websocket connection.") + logger.error( + "Tried to send a payload without a websocket connection." + ) return await self.client.rate_limiter.tick() logger.debug("Sending %s to Gateway", payload) diff --git a/EpikCord/ext/tasks.py b/EpikCord/ext/tasks.py index ea8f3045..118a98a6 100644 --- a/EpikCord/ext/tasks.py +++ b/EpikCord/ext/tasks.py @@ -6,7 +6,10 @@ class Task: def __init__( - self, wrapped_func: Callable[..., Coroutine], duration: float, max_runs: int + self, + wrapped_func: Callable[..., Coroutine], + duration: float, + max_runs: int, ): self.wrapped_func: Callable[..., Coroutine] = wrapped_func self.duration: float = duration diff --git a/EpikCord/file.py b/EpikCord/file.py index e4072e62..4cc00780 100644 --- a/EpikCord/file.py +++ b/EpikCord/file.py @@ -30,7 +30,9 @@ def __init__( The description of the file. """ self.contents: io.IOBase = contents - self.mime_type: Optional[str] = mime_type or mimetypes.guess_type(filename)[0] + self.mime_type: Optional[str] = ( + mime_type or mimetypes.guess_type(filename)[0] + ) if spoiler: self.filename = f"SPOILER_{filename}" else: diff --git a/EpikCord/flags.py b/EpikCord/flags.py index 74b0ec6a..043f7810 100644 --- a/EpikCord/flags.py +++ b/EpikCord/flags.py @@ -7,7 +7,9 @@ class Flag: class_flags: Dict[str, int] def __init_subclass__(cls) -> None: - cls.class_flags = {k: v for k, v in cls.__dict__.items() if isinstance(v, int)} + cls.class_flags = { + k: v for k, v in cls.__dict__.items() if isinstance(v, int) + } def __init__(self, value: int = 0, **kwargs): self.value = value @@ -128,4 +130,10 @@ class ChannelFlags(Flag): require_tag = 1 << 4 -__all__ = ("Intents", "SystemChannelFlags", "Permissions", "Flag", "ChannelFlags") +__all__ = ( + "Intents", + "SystemChannelFlags", + "Permissions", + "Flag", + "ChannelFlags", +) diff --git a/EpikCord/presence.py b/EpikCord/presence.py index a7ba83fb..7656eb1c 100644 --- a/EpikCord/presence.py +++ b/EpikCord/presence.py @@ -4,7 +4,9 @@ from typing_extensions import NotRequired -class ActivityPayload(TypedDict): # Data sent to Discord when updating presence +class ActivityPayload( + TypedDict +): # Data sent to Discord when updating presence name: str type: int url: NotRequired[str] @@ -27,7 +29,9 @@ class ActivityType(IntEnum): COMPETING = 5 -class UpdatePresenceData(TypedDict): # Data sent to Discord when updating presence +class UpdatePresenceData( + TypedDict +): # Data sent to Discord when updating presence status: NotRequired[str] activities: NotRequired[List[ActivityPayload]] afk: bool @@ -35,7 +39,9 @@ class UpdatePresenceData(TypedDict): # Data sent to Discord when updating prese class Activity: - def __init__(self, *, name: str, type: ActivityType, url: Optional[str] = None): + def __init__( + self, *, name: str, type: ActivityType, url: Optional[str] = None + ): """Represents a Discord activity. Parameters @@ -66,7 +72,10 @@ def to_dict(self) -> ActivityPayload: class Presence: def __init__( - self, *, status: Optional[Status] = None, activity: Optional[Activity] = None + self, + *, + status: Optional[Status] = None, + activity: Optional[Activity] = None, ): """Represents a Discord presence. @@ -78,7 +87,9 @@ def __init__( The activity of the presence. """ if not status and not activity: - raise ValueError("Presence must have either a status or an activity.") + raise ValueError( + "Presence must have either a status or an activity." + ) self.status: Optional[Status] = status self.activity: Optional[Activity] = activity diff --git a/EpikCord/utils/loose.py b/EpikCord/utils/loose.py index cc7a78f3..0517a323 100644 --- a/EpikCord/utils/loose.py +++ b/EpikCord/utils/loose.py @@ -100,7 +100,8 @@ def cleanup_loop(loop) -> None: async def log_request(res, body: Optional[dict] = None): """Logs information about the request.""" messages = [ - f"Sent a {res.method} to {res.url} " f"and got a {res.status} response. ", + f"Sent a {res.method} to {res.url} " + f"and got a {res.status} response. ", f"Content-Type: {res.headers['Content-Type']} ", ] @@ -120,7 +121,9 @@ async def log_request(res, body: Optional[dict] = None): logger.debug("".join(messages)) -def add_file(form: aiohttp.FormData, file: File, i: int) -> SendingAttachmentData: +def add_file( + form: aiohttp.FormData, file: File, i: int +) -> SendingAttachmentData: form.add_field( f"files[{i}]", file.contents, From b7cd1860bf368364a2ce23c218ca9c220f380549 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 17:25:18 +0100 Subject: [PATCH 074/335] :adhesive_bandage: PyRight is broken without deps --- noxfile.py | 1 + 1 file changed, 1 insertion(+) diff --git a/noxfile.py b/noxfile.py index d92e77ab..589938db 100644 --- a/noxfile.py +++ b/noxfile.py @@ -23,5 +23,6 @@ def lint(session: nox.Session): @nox.session def pyright(session: nox.Session): session.install("pyright") + session.install("orjson", "types-orjson") session.install("-r", "requirements.txt") session.run("pyright", "EpikCord") From a4bf4d58ef06255a34b23b835078cf8cccb9c9d7 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 17:28:54 +0100 Subject: [PATCH 075/335] :construction_worker: Make CI job dependent on black --- .github/workflows/black.yml | 31 ------------------------------- .github/workflows/ci.yml | 25 +++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 31 deletions(-) delete mode 100644 .github/workflows/black.yml diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml deleted file mode 100644 index 3e130511..00000000 --- a/.github/workflows/black.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Black - -on: - workflow_dispatch: - push: - -jobs: - deploy: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - - name: Set Up git - run: | - git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" - git config --local user.name "github-actions[bot]" - git pull - - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: '3.8' - - - name: run Formatter - run: | - make pretty - - name: Push changes - run: | - git commit -m ":art: Format code automatically" -a || echo "No changes to commit" - git push || echo "No changes to push" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f75652b..e85a6bfe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,32 @@ on: pull_request: jobs: + black: + runs-on: ${{ matrix.os }}-latest + steps: + - uses: actions/checkout@v2 + + - name: Set Up git + run: | + git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + git pull + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.8' + + - name: run Formatter + run: | + make pretty + - name: Push changes + run: | + git commit -m ":art: Format code automatically" -a || echo "No changes to commit" + git push || echo "No changes to push" + ci: + needs: black runs-on: ${{ matrix.os }}-latest strategy: matrix: From 1d2a92b9b612b41af10dc275f6ebc27a7dddf3f6 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 17:31:25 +0100 Subject: [PATCH 076/335] :green_heart: Make black runs on ubuntu latest --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e85a6bfe..cf20d905 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,7 @@ on: jobs: black: - runs-on: ${{ matrix.os }}-latest + runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 From 39de4fc28e1771ee0490871d148a44b29ce81e47 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 17:33:44 +0100 Subject: [PATCH 077/335] :green_heart: Change run settings for CI --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf20d905..dbf96081 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,8 @@ name: CI on: pull_request: + workflow_dispatch: + push: jobs: black: From 29d1dddaef871100035a9e3f4a86603d373dd23e Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 17:40:08 +0100 Subject: [PATCH 078/335] :construction: Add branch name? --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbf96081..4fc606c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,13 @@ jobs: black: runs-on: ubuntu-latest steps: + - name: Get branch names. + id: branch-names + uses: tj-actions/branch-names@v6 + - uses: actions/checkout@v2 + with: + ref: ${{ steps.branch-names.outputs.head_ref_branch }} - name: Set Up git run: | From 2bea3acad99b2b8f9255f296fa428203229479d8 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 17:51:09 +0100 Subject: [PATCH 079/335] :wrench: Fix indent size for yml --- .editorconfig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.editorconfig b/.editorconfig index 9f56f42b..59da8a53 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,3 +8,7 @@ indent_style = space insert_final_newline = false max_line_length = 80 tab_width = 4 + +[*.yml] +tab_width = 2 +indent_size = 2 From 316c4b5530c48944f1b6cd6f79c26b81fc325940 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 17:51:57 +0100 Subject: [PATCH 080/335] :construction_worker: Split CI to use matrix only for the "install" session --- .github/workflows/ci.yml | 48 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4fc606c5..a08b0435 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,17 +36,57 @@ jobs: git commit -m ":art: Format code automatically" -a || echo "No changes to commit" git push || echo "No changes to push" - ci: + lint: needs: black + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Python Setup + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python }} + architecture: 'x64' + + - name: Update Pip + run: pip install -U pip + + - name: Install dependencies + run: pip install nox git+https://github.com/Bluenix2/discord-typings + + - name: Run nox + run: nox -s lint + + pyright: + needs: black + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Python Setup + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python }} + architecture: 'x64' + + - name: Update Pip + run: pip install -U pip + + - name: Install dependencies + run: pip install nox git+https://github.com/Bluenix2/discord-typings + + - name: Run nox + run: nox -s pyright + + install: runs-on: ${{ matrix.os }}-latest strategy: matrix: python: [ '3.8', '3.9', '3.10' ] os: [ ubuntu, windows, macos ] - nox_action: ["install", "lint", "pyright"] fail-fast: false - name: Nox - py ${{ matrix.python }} - ${{ matrix.os }} - ${{ matrix.nox_action }} + name: Nox - py ${{ matrix.python }} - ${{ matrix.os }} steps: - uses: actions/checkout@v2 @@ -63,4 +103,4 @@ jobs: run: pip install nox git+https://github.com/Bluenix2/discord-typings - name: Run nox - run: nox -s ${{ matrix.nox_action }} + run: nox -s install From 54ab4945df944af038edbafea09d31bfe6666659 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 18:07:40 +0100 Subject: [PATCH 081/335] :construction_worker: Include codespell within CI --- .github/workflows/ci.yml | 13 ++++++++++++- .github/workflows/codespell.yml | 10 ---------- 2 files changed, 12 insertions(+), 11 deletions(-) delete mode 100644 .github/workflows/codespell.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a08b0435..031ba45b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,12 +31,24 @@ jobs: - name: run Formatter run: | make pretty + - name: Push changes run: | git commit -m ":art: Format code automatically" -a || echo "No changes to commit" git push || echo "No changes to push" + codespell: + if: ${{ always() }} + needs: black + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + - run: pip install codespell + - run: codespell --skip="./docs/*" + lint: + if: ${{ always() }} needs: black runs-on: ubuntu-latest steps: @@ -58,7 +70,6 @@ jobs: run: nox -s lint pyright: - needs: black runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml deleted file mode 100644 index 832f533d..00000000 --- a/.github/workflows/codespell.yml +++ /dev/null @@ -1,10 +0,0 @@ -name: codespell -on: [pull_request, push] -jobs: - codespell: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - - run: pip install codespell - - run: codespell --skip="./docs/*" \ No newline at end of file From e6c6a1c6b8ce2a14ff588e6ace7decbe18351e13 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 18:15:24 +0100 Subject: [PATCH 082/335] :construction_worker: Include bandit within CI --- .github/workflows/bandit.yml | 10 ---------- .github/workflows/ci.yml | 8 ++++++++ 2 files changed, 8 insertions(+), 10 deletions(-) delete mode 100644 .github/workflows/bandit.yml diff --git a/.github/workflows/bandit.yml b/.github/workflows/bandit.yml deleted file mode 100644 index 977bcabe..00000000 --- a/.github/workflows/bandit.yml +++ /dev/null @@ -1,10 +0,0 @@ -name: bandit -on: [pull_request, push] -jobs: - bandit: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - - run: pip install bandit - - run: bandit --recursive . \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 031ba45b..ad71cc80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,14 @@ on: push: jobs: + bandit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + - run: pip install bandit + - run: bandit --recursive . + black: runs-on: ubuntu-latest steps: From d2f7ac015648cebe86152775d9ac7821cd9e59d9 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 18:21:27 +0100 Subject: [PATCH 083/335] :adhesive_bandage: Avoid overlapping run --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad71cc80..bade0a5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,7 @@ name: CI on: pull_request: + types: [opened, reopened] workflow_dispatch: push: From 359bf0b86b204dbf419efd7b1752982359464f7e Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 18:41:31 +0100 Subject: [PATCH 084/335] :recycle: Using clear import to avoid globals pollution --- EpikCord/__init__.py | 41 ++++++++++++++++++++++++++++++++----- EpikCord/client/__init__.py | 11 ++++++++-- EpikCord/ext/__init__.py | 4 +++- EpikCord/flags.py | 9 -------- EpikCord/utils/__init__.py | 41 +++++++++++++++++++++++++++++++++++-- 5 files changed, 87 insertions(+), 19 deletions(-) diff --git a/EpikCord/__init__.py b/EpikCord/__init__.py index 8ac6c73b..4f701039 100644 --- a/EpikCord/__init__.py +++ b/EpikCord/__init__.py @@ -1,7 +1,38 @@ __version__ = "0.6.0" -from .client import * -from .exceptions import * -from .ext import * -from .file import * -from .flags import * +from .client import APIVersion, Client, TokenStore, HTTPClient +from .exceptions import ( + EpikCordException, + HTTPException, + NotFound, + Forbidden, + Unauthorized, + BadRequest, + TooManyRetries, +) +from .ext import Task, task +from .file import File +from .flags import Flag, Intents, SystemChannelFlags, Permissions, ChannelFlags + + +__all__ = ( + "APIVersion", + "BadRequest", + "ChannelFlags", + "Client", + "EpikCordException", + "File", + "Flag", + "Forbidden", + "HTTPClient", + "HTTPException", + "Intents", + "NotFound", + "Permissions", + "SystemChannelFlags", + "Task", + "TokenStore", + "TooManyRetries", + "Unauthorized", + "task", +) diff --git a/EpikCord/client/__init__.py b/EpikCord/client/__init__.py index df063a3d..83d5c78b 100644 --- a/EpikCord/client/__init__.py +++ b/EpikCord/client/__init__.py @@ -1,2 +1,9 @@ -from .client import * -from .http import * +from .client import Client, TokenStore +from .http import APIVersion, HTTPClient + +__all__ = ( + "APIVersion", + "Client", + "TokenStore", + "HTTPClient", +) diff --git a/EpikCord/ext/__init__.py b/EpikCord/ext/__init__.py index f5456c32..fc2d1abb 100644 --- a/EpikCord/ext/__init__.py +++ b/EpikCord/ext/__init__.py @@ -1 +1,3 @@ -from .tasks import * +from .tasks import Task, task + +__all__ = ("Task", "task") diff --git a/EpikCord/flags.py b/EpikCord/flags.py index 043f7810..63af8774 100644 --- a/EpikCord/flags.py +++ b/EpikCord/flags.py @@ -128,12 +128,3 @@ class Permissions(Flag): class ChannelFlags(Flag): pinned = 1 << 1 require_tag = 1 << 4 - - -__all__ = ( - "Intents", - "SystemChannelFlags", - "Permissions", - "Flag", - "ChannelFlags", -) diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py index 0cabb2a7..27d16945 100644 --- a/EpikCord/utils/__init__.py +++ b/EpikCord/utils/__init__.py @@ -1,2 +1,39 @@ -from .loose import * -from .types import * +from .loose import ( + clear_none_values, + json_serialize, + clean_url, + extract_content, + singleton, + cancel_tasks, + cleanup_loop, + log_request, + add_file +) +from .types import ( + OpCode, + GatewayCloseCode, + VoiceOpCode, + VoiceCloseCode, + IdentifyData, + IdentifyCommand, + SendingAttachmentData +) + +__all__ = ( + "GatewayCloseCode", + "OpCode", + "IdentifyData", + "IdentifyCommand", + "SendingAttachmentData", + "VoiceCloseCode", + "VoiceOpCode", + "add_file", + "cancel_tasks", + "clean_url", + "cleanup_loop", + "clear_none_values", + "extract_content", + "json_serialize", + "log_request", + "singleton", +) From 46547462692cbb00f981532a55dae90ebc65a2b2 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 18:41:59 +0100 Subject: [PATCH 085/335] :recycle: Fix line length --- EpikCord/client/client.py | 5 ++++- EpikCord/client/http.py | 13 +++++++++---- EpikCord/client/rate_limit_handling_tools.py | 7 +++++-- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index 62737227..97e6db9e 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -15,7 +15,10 @@ def __init__(self, token: str): class Client(WebSocketClient): - """The main class of EpikCord. Use this to interact with the Discord API and Gateway.""" + """ + The main class of EpikCord. + Use this to interact with the Discord API and Gateway. + """ def __init__( self, diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index cd869d1d..99567313 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -63,14 +63,16 @@ def __init__( Parameters ---------- token: str - The token of the bot. Used as authorization. Should be kept private at all times. + The token of the bot. Used as authorization. + Should be kept private at all times. version: int The version of the Discord API to use. Defaults to 10. Attributes ---------- token: str - The token of the bot. Used as authorization. Should be kept private at all times. + The token of the bot. Used as authorization. + Should be kept private at all times. version: int The version of the Discord API to use. session: aiohttp.ClientSession @@ -87,7 +89,10 @@ def __init__( self.session: aiohttp.ClientSession = aiohttp.ClientSession( headers={ "Authorization": f"Bot {self.token}", - "User-Agent": f"DiscordBot (https://github.com/EpikCord/EpikCord.py {__version__})", + "User-Agent": ( + "DiscordBot " + f"(https://github.com/EpikCord/EpikCord.py {__version__})" + ), }, json_serialize=json_serialize, # type: ignore ws_response_class=GatewayWebSocket, @@ -135,7 +140,7 @@ async def request( BadRequest If the request returns a 400 status code. HTTPException - If the request returns a status code that is not OK and not any of the other exceptions. + Generic Exception for unhandled errors that may occur. """ method = route.method diff --git a/EpikCord/client/rate_limit_handling_tools.py b/EpikCord/client/rate_limit_handling_tools.py index e583cb62..275c88cf 100644 --- a/EpikCord/client/rate_limit_handling_tools.py +++ b/EpikCord/client/rate_limit_handling_tools.py @@ -198,8 +198,11 @@ def __eq__(self, other: Any): return self.major_parameters == other.major_parameters return False - def __str__(self): - return f"{self.channel_id}:{self.guild_id}:{self.webhook_id}:{self.webhook_token}" + def __str__(self) -> str: + return ( + f"{self.channel_id}:{self.guild_id}" + f":{self.webhook_id}:{self.webhook_token}" + ) async def handle_exhaustion(self, retry_after: int): """ From e792df0b485d0342578272192a9a24def73ca0cd Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 18:43:15 +0100 Subject: [PATCH 086/335] :bug: Add missing AsyncFunction import --- EpikCord/utils/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py index 27d16945..5adb80b5 100644 --- a/EpikCord/utils/__init__.py +++ b/EpikCord/utils/__init__.py @@ -10,6 +10,7 @@ add_file ) from .types import ( + AsyncFunction, OpCode, GatewayCloseCode, VoiceOpCode, @@ -20,6 +21,7 @@ ) __all__ = ( + "AsyncFunction", "GatewayCloseCode", "OpCode", "IdentifyData", From 67330611316dc4fb8fbdd12af87ac05b67933bd7 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 19:10:57 +0100 Subject: [PATCH 087/335] :white_check_mark: Add a minimal e2e setup --- tests/__init__.py | 0 tests/e2e/__init__.py | 0 tests/e2e/test_bot_instantiation.py | 24 ++++++++++++++++++++++++ tests/e2e/test_imports.py | 16 ++++++++++++++++ 4 files changed, 40 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/e2e/__init__.py create mode 100644 tests/e2e/test_bot_instantiation.py create mode 100644 tests/e2e/test_imports.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/e2e/test_bot_instantiation.py b/tests/e2e/test_bot_instantiation.py new file mode 100644 index 00000000..eb62878c --- /dev/null +++ b/tests/e2e/test_bot_instantiation.py @@ -0,0 +1,24 @@ +import logging +import sys + +from EpikCord import Client, TokenStore, Intents +from EpikCord.client import * +from EpikCord.utils import * + + +logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + + +class Bot(Client): + + def __init__(self, token: str): + super().__init__( + token=TokenStore(token), + intents=Intents.all() + ) + + +def test_bot_instantiation(): + """Check that instantiating to not raise any errors.""" + assert Bot(sys.argv.pop()) diff --git a/tests/e2e/test_imports.py b/tests/e2e/test_imports.py new file mode 100644 index 00000000..f58294f0 --- /dev/null +++ b/tests/e2e/test_imports.py @@ -0,0 +1,16 @@ +from EpikCord import * +from EpikCord.client import * +from EpikCord.ext import * +from EpikCord.utils import * + + +def test_imports(): + """ + This test is meant to check that library imports are correct and do not + lead to any errors such as circular imports. + """ + assert APIVersion + assert Client + assert Intents + assert Task + assert singleton From ba979c01b8d46cf1559aa2b3819e741bbd86523d Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 19:31:48 +0100 Subject: [PATCH 088/335] :recycle: Improve circular import checker --- tests/e2e/test_imports.py | 16 ---------------- tests/test_circular_imports.py | 24 ++++++++++++++++++++++++ 2 files changed, 24 insertions(+), 16 deletions(-) delete mode 100644 tests/e2e/test_imports.py create mode 100644 tests/test_circular_imports.py diff --git a/tests/e2e/test_imports.py b/tests/e2e/test_imports.py deleted file mode 100644 index f58294f0..00000000 --- a/tests/e2e/test_imports.py +++ /dev/null @@ -1,16 +0,0 @@ -from EpikCord import * -from EpikCord.client import * -from EpikCord.ext import * -from EpikCord.utils import * - - -def test_imports(): - """ - This test is meant to check that library imports are correct and do not - lead to any errors such as circular imports. - """ - assert APIVersion - assert Client - assert Intents - assert Task - assert singleton diff --git a/tests/test_circular_imports.py b/tests/test_circular_imports.py new file mode 100644 index 00000000..ed295cf4 --- /dev/null +++ b/tests/test_circular_imports.py @@ -0,0 +1,24 @@ +import os +import logging + +logger = logging.getLogger(__name__) + + +def test_imports(): + """ + This test is meant to check that library imports are correct and do not + lead to any errors such as circular imports. + """ + for path, sub_dirs, files in os.walk("EpikCord"): + if "__init__.py" not in files: + continue + + module_name = path.replace("/", ".") + + try: + module = __import__(module_name) + except ImportError as e: + logger.error(f"Error importing {module_name}: {e}") + raise e + + assert len(dir(module)) > 0 From 863da6c42c2894ba69d5196a8d0f19fc6cc8a412 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 19:32:46 +0100 Subject: [PATCH 089/335] :sparkles: Add pytest nox sessions --- noxfile.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/noxfile.py b/noxfile.py index 589938db..e7fe075d 100644 --- a/noxfile.py +++ b/noxfile.py @@ -26,3 +26,19 @@ def pyright(session: nox.Session): session.install("orjson", "types-orjson") session.install("-r", "requirements.txt") session.run("pyright", "EpikCord") + + +@nox.session(name="imports") +def check_circular_imports(session: nox.Session): + session.install("pytest") + session.install("-r", "requirements.txt") + session.install(".") + session.run("pytest", "tests/test_circular_imports.py") + + +@nox.session(name="e2e") +def end_to_end(session: nox.Session): + session.install("pytest") + session.install("-r", "requirements.txt") + session.install(".") + session.run("pytest", "tests/e2e") From 53480ca03924a2480f0190021354ab1acd487eb5 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 19:41:49 +0100 Subject: [PATCH 090/335] :construction_worker: Add e2e & circular import check in CI --- .github/workflows/ci.yml | 49 ++++++++++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bade0a5c..a4622f89 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,9 +69,6 @@ jobs: python-version: ${{ matrix.python }} architecture: 'x64' - - name: Update Pip - run: pip install -U pip - - name: Install dependencies run: pip install nox git+https://github.com/Bluenix2/discord-typings @@ -89,9 +86,6 @@ jobs: python-version: ${{ matrix.python }} architecture: 'x64' - - name: Update Pip - run: pip install -U pip - - name: Install dependencies run: pip install nox git+https://github.com/Bluenix2/discord-typings @@ -116,11 +110,48 @@ jobs: python-version: ${{ matrix.python }} architecture: 'x64' - - name: Update Pip - run: pip install -U pip - - name: Install dependencies run: pip install nox git+https://github.com/Bluenix2/discord-typings - name: Run nox run: nox -s install + + circular_imports: + needs: install + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: 3.8 + architecture: 'x64' + + - name: Install dependencies + run: pip install nox git+https://github.com/Bluenix2/discord-typings + + - name: Check for circular imports + run: nox -s circular_imports + + e2e: + needs: circular_imports + runs-on: ${{ matrix.os }}-latest + strategy: + matrix: + python: [ '3.8', '3.9', '3.10' ] + os: [ ubuntu, windows, macos ] + fail-fast: false + + steps: + - uses: actions/checkout@v2 + + - name: Python Setup + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python }} + architecture: 'x64' + + - name: Install dependencies + run: pip install nox git+https://github.com/Bluenix2/discord-typings + + - name: Run E2E tests + run: nox -s e2e From 266991bd39023ba2236680c2af8cea0fb0c124ff Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 28 Jan 2023 18:42:26 +0000 Subject: [PATCH 091/335] :art: Format code automatically --- EpikCord/__init__.py | 11 +++++------ EpikCord/utils/__init__.py | 22 +++++++++++----------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/EpikCord/__init__.py b/EpikCord/__init__.py index 4f701039..b597336e 100644 --- a/EpikCord/__init__.py +++ b/EpikCord/__init__.py @@ -1,19 +1,18 @@ __version__ = "0.6.0" -from .client import APIVersion, Client, TokenStore, HTTPClient +from .client import APIVersion, Client, HTTPClient, TokenStore from .exceptions import ( + BadRequest, EpikCordException, + Forbidden, HTTPException, NotFound, - Forbidden, - Unauthorized, - BadRequest, TooManyRetries, + Unauthorized, ) from .ext import Task, task from .file import File -from .flags import Flag, Intents, SystemChannelFlags, Permissions, ChannelFlags - +from .flags import ChannelFlags, Flag, Intents, Permissions, SystemChannelFlags __all__ = ( "APIVersion", diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py index 5adb80b5..14eba95e 100644 --- a/EpikCord/utils/__init__.py +++ b/EpikCord/utils/__init__.py @@ -1,23 +1,23 @@ from .loose import ( - clear_none_values, - json_serialize, - clean_url, - extract_content, - singleton, + add_file, cancel_tasks, + clean_url, cleanup_loop, + clear_none_values, + extract_content, + json_serialize, log_request, - add_file + singleton, ) from .types import ( AsyncFunction, - OpCode, GatewayCloseCode, - VoiceOpCode, - VoiceCloseCode, - IdentifyData, IdentifyCommand, - SendingAttachmentData + IdentifyData, + OpCode, + SendingAttachmentData, + VoiceCloseCode, + VoiceOpCode, ) __all__ = ( From f6fd16c78abb56348eb6864ea04283a76702665c Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 19:43:46 +0100 Subject: [PATCH 092/335] :green_heart: Run bandit only within the package --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4622f89..cae01ba5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 - run: pip install bandit - - run: bandit --recursive . + - run: bandit --recursive EpikCord black: runs-on: ubuntu-latest From 83ba91a0739e09003e4382ae8b13a33eee1af6ed Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 19:47:47 +0100 Subject: [PATCH 093/335] :green_heart: Fix session name --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cae01ba5..9ca20b43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,7 +130,7 @@ jobs: run: pip install nox git+https://github.com/Bluenix2/discord-typings - name: Check for circular imports - run: nox -s circular_imports + run: nox -s imports e2e: needs: circular_imports From 33eecd960370abd0ce1d25ecf26cabb709c4b950 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 19:52:30 +0100 Subject: [PATCH 094/335] :zap: Caching pip packages in CI --- .github/workflows/ci.yml | 47 +++++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ca20b43..832d19a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,8 +10,11 @@ jobs: bandit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: 3.8 + cache: 'pip' - run: pip install bandit - run: bandit --recursive EpikCord @@ -33,9 +36,10 @@ jobs: git pull - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: '3.8' + cache: 'pip' - name: run Formatter run: | @@ -51,8 +55,11 @@ jobs: needs: black runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: 3.8 + cache: 'pip' - run: pip install codespell - run: codespell --skip="./docs/*" @@ -61,13 +68,13 @@ jobs: needs: black runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Python Setup - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python }} - architecture: 'x64' + cache: 'pip' - name: Install dependencies run: pip install nox git+https://github.com/Bluenix2/discord-typings @@ -78,13 +85,13 @@ jobs: pyright: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Python Setup - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python }} - architecture: 'x64' + cache: 'pip' - name: Install dependencies run: pip install nox git+https://github.com/Bluenix2/discord-typings @@ -102,13 +109,13 @@ jobs: name: Nox - py ${{ matrix.python }} - ${{ matrix.os }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Python Setup - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python }} - architecture: 'x64' + cache: 'pip' - name: Install dependencies run: pip install nox git+https://github.com/Bluenix2/discord-typings @@ -120,11 +127,11 @@ jobs: needs: install runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 with: python-version: 3.8 - architecture: 'x64' + cache: 'pip' - name: Install dependencies run: pip install nox git+https://github.com/Bluenix2/discord-typings @@ -142,13 +149,13 @@ jobs: fail-fast: false steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Python Setup - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python }} - architecture: 'x64' + cache: 'pip' - name: Install dependencies run: pip install nox git+https://github.com/Bluenix2/discord-typings From 3bdf4ae2a18dc4bb0957bf8d341ebe98c1e6a6b4 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 20:01:35 +0100 Subject: [PATCH 095/335] :bug: Changing setup-python version --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 832d19a6..3adbfa67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,7 +71,7 @@ jobs: - uses: actions/checkout@v3 - name: Python Setup - uses: actions/setup-python@v4 + uses: actions/setup-python@v2 with: python-version: ${{ matrix.python }} cache: 'pip' @@ -88,7 +88,7 @@ jobs: - uses: actions/checkout@v3 - name: Python Setup - uses: actions/setup-python@v4 + uses: actions/setup-python@v2 with: python-version: ${{ matrix.python }} cache: 'pip' From dc8d7a1ee112afb6544ab0e47a5a227d50aa3f38 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 20:05:22 +0100 Subject: [PATCH 096/335] :bug: Set Pyright to output json --- noxfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index e7fe075d..573a3155 100644 --- a/noxfile.py +++ b/noxfile.py @@ -25,7 +25,7 @@ def pyright(session: nox.Session): session.install("pyright") session.install("orjson", "types-orjson") session.install("-r", "requirements.txt") - session.run("pyright", "EpikCord") + session.run("pyright", "EpikCord", "--outputjson", "pyright.json") @nox.session(name="imports") From 88f8f65ca478ec7cd0cce3c1633171281a64f2fb Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 20:10:35 +0100 Subject: [PATCH 097/335] :bug: Maybe this will fix pyright --- noxfile.py | 2 +- pyproject.toml | 21 ++++++++++++++------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/noxfile.py b/noxfile.py index 573a3155..e7fe075d 100644 --- a/noxfile.py +++ b/noxfile.py @@ -25,7 +25,7 @@ def pyright(session: nox.Session): session.install("pyright") session.install("orjson", "types-orjson") session.install("-r", "requirements.txt") - session.run("pyright", "EpikCord", "--outputjson", "pyright.json") + session.run("pyright", "EpikCord") @nox.session(name="imports") diff --git a/pyproject.toml b/pyproject.toml index b5208980..ffb29698 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,14 @@ -[build-system] -requires = ["setuptools"] -build-backend = "setuptools.build_meta" - -[tool.black] -line-length = 79 -target-version = ['py38'] +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[tool.black] +line-length = 79 +target-version = ['py38'] + +[tool.pyright] +include = ["EpikCord"] +exclude = [ + "venv", + "**/__pycache__" +] From 636bea0e206fece864233767923f0ee10cc56e90 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 20:12:32 +0100 Subject: [PATCH 098/335] :bug: Maybe this will fix pyright --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3adbfa67..21b7f1a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,7 +91,7 @@ jobs: uses: actions/setup-python@v2 with: python-version: ${{ matrix.python }} - cache: 'pip' + architecture: 'x64' - name: Install dependencies run: pip install nox git+https://github.com/Bluenix2/discord-typings From d8d8daa622e3034dd5700c014f8e7de14f0f4066 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 20:14:21 +0100 Subject: [PATCH 099/335] :bug: Run CI lint on x64 --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21b7f1a5..d2485de0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,6 +74,7 @@ jobs: uses: actions/setup-python@v2 with: python-version: ${{ matrix.python }} + architecture: 'x64' cache: 'pip' - name: Install dependencies @@ -92,6 +93,7 @@ jobs: with: python-version: ${{ matrix.python }} architecture: 'x64' + cache: 'pip' - name: Install dependencies run: pip install nox git+https://github.com/Bluenix2/discord-typings From d62eb335667a669584b59029097f86b7779076e6 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 20:15:51 +0100 Subject: [PATCH 100/335] :bug: Remove cache for lint part --- .github/workflows/ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2485de0..e76a380a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,7 +75,6 @@ jobs: with: python-version: ${{ matrix.python }} architecture: 'x64' - cache: 'pip' - name: Install dependencies run: pip install nox git+https://github.com/Bluenix2/discord-typings @@ -93,7 +92,6 @@ jobs: with: python-version: ${{ matrix.python }} architecture: 'x64' - cache: 'pip' - name: Install dependencies run: pip install nox git+https://github.com/Bluenix2/discord-typings From 7d1614b17ef12519d88c0331324f4c8f16a27490 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 20:31:31 +0100 Subject: [PATCH 101/335] :construction_worker: Add coverage report to report --- .github/workflows/ci.yml | 22 ++++++++++++++++++++++ .gitignore | 4 ++++ noxfile.py | 9 +++++++++ 3 files changed, 35 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e76a380a..c608c0c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -162,3 +162,25 @@ jobs: - name: Run E2E tests run: nox -s e2e + + coverage: + needs: e2e + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Python Setup + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python }} + cache: 'pip' + + - name: Install dependencies + run: pip install nox git+https://github.com/Bluenix2/discord-typings + + - name: Run coverage + run: nox -s coverage + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v2 diff --git a/.gitignore b/.gitignore index f86efba5..1ac93f74 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,7 @@ RoboEpikLogs.log epikcord.log *.mp3 *.mp4 + +# Coverage +.coverage +coverage.xml \ No newline at end of file diff --git a/noxfile.py b/noxfile.py index e7fe075d..fdaad8c6 100644 --- a/noxfile.py +++ b/noxfile.py @@ -42,3 +42,12 @@ def end_to_end(session: nox.Session): session.install("-r", "requirements.txt") session.install(".") session.run("pytest", "tests/e2e") + + +@nox.session +def coverage_report(session: nox.Session): + session.install("pytest", "pytest-cov", "coverage") + session.install("-r", "requirements.txt") + session.install(".") + session.run("pytest", "--cov=EpikCord", "tests") + session.run("coverage", "xml") From a7f83a922d0e109405a55926bf17d462843338a0 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 20:36:39 +0100 Subject: [PATCH 102/335] :construction_worker: Add sourcery on CI --- .github/workflows/ci.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c608c0c0..b164b03c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -179,8 +179,20 @@ jobs: - name: Install dependencies run: pip install nox git+https://github.com/Bluenix2/discord-typings - - name: Run coverage + - name: Run coverage_report run: nox -s coverage - name: Upload coverage to Codecov uses: codecov/codecov-action@v2 + + review-with-sourcery: + needs: black + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-python@v4 + with: + python-version: '3.8' + + - uses: sourcery-ai/action@v1 From 717ebcd9ba4c57aaa91c4f756768b72a107ce676 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 20:39:56 +0100 Subject: [PATCH 103/335] :fire: Sourcery require my token --- .github/workflows/ci.yml | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b164b03c..bfa86fe7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -184,15 +184,3 @@ jobs: - name: Upload coverage to Codecov uses: codecov/codecov-action@v2 - - review-with-sourcery: - needs: black - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - uses: actions/setup-python@v4 - with: - python-version: '3.8' - - - uses: sourcery-ai/action@v1 From f05026d998f45e622b77d369cba21511c858679b Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 20:41:58 +0100 Subject: [PATCH 104/335] :green_heart: Fx python version for codecov --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfa86fe7..7141659f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -173,7 +173,7 @@ jobs: - name: Python Setup uses: actions/setup-python@v4 with: - python-version: ${{ matrix.python }} + python-version: 3.8 cache: 'pip' - name: Install dependencies From 3a6f57cb9b1f538e64511ea0a694c3d6612eb31c Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 20:48:10 +0100 Subject: [PATCH 105/335] :green_heart: Fx nox session name --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7141659f..83c064d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -179,8 +179,8 @@ jobs: - name: Install dependencies run: pip install nox git+https://github.com/Bluenix2/discord-typings - - name: Run coverage_report - run: nox -s coverage + - name: Run coverage + run: nox -s coverage_report - name: Upload coverage to Codecov uses: codecov/codecov-action@v2 From 369c3fa059121660add80a1b6588644a7058db2c Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sat, 28 Jan 2023 20:42:52 +0000 Subject: [PATCH 106/335] Make integers in errors be list indexes --- EpikCord/client/http.py | 2 +- EpikCord/exceptions.py | 55 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 99567313..3fcc3c74 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -178,7 +178,7 @@ async def request( error = self.error_mapping.get( response.status, HTTPException ) - raise error(response, data) + raise error(data) elif response.ok: return response raise TooManyRetries( diff --git a/EpikCord/exceptions.py b/EpikCord/exceptions.py index 42167e70..09053503 100644 --- a/EpikCord/exceptions.py +++ b/EpikCord/exceptions.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from typing import Dict import aiohttp @@ -6,10 +7,60 @@ class EpikCordException(Exception): ... +@dataclass +class LocatedError: + code: str + message: str + path: str + class HTTPException(EpikCordException): - def __init__(self, response: aiohttp.ClientResponse, data: Dict): - ... + def __init__(self, data: Dict): + self.body = data + self.code = data.get("code") + self.message = data.get("message") + self.errors = data.get("errors") + self.errors_list = self.extract_errors(self.errors) + + super().__init__( + "\n".join(f"{e.path} - {e.code} - {e.message}" for e in self.errors_list) + ) + + # def extract_errors(self, d, key_path=None): + # """Get _errors key from the given dictionary.""" + # if key_path is None: + # key_path = [] + + # if d.get("_errors"): + # return [ + # LocatedError(**error, path=".".join(key_path[1:])) + # for error in d.get("_errors", []) + # ] + + # return [ + # x + # for k, v in d.items() + # if isinstance(v, dict) + # for x in self.extract_errors(v, key_path + [k]) + # ] + + def extract_errors(self, d, key_path=None): + """Get _errors key from the given dictionary.""" + if key_path is None: + key_path = [] + + if d.get("_errors"): + return [ + LocatedError(**error, path=".".join(f'{k}[{k}]' if isinstance(k, int) else k for k in key_path[1:])) + for error in d.get("_errors", []) + ] + + return [ + x + for k, v in d.items() + if isinstance(v, dict) + for x in self.extract_errors(v, key_path + [k]) + ] class NotFound(HTTPException): From 4cd776a9c9a5782faf6bca1062ee558b57ca61c2 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sat, 28 Jan 2023 20:43:26 +0000 Subject: [PATCH 107/335] Remove commented out function --- EpikCord/exceptions.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/EpikCord/exceptions.py b/EpikCord/exceptions.py index 09053503..60d10287 100644 --- a/EpikCord/exceptions.py +++ b/EpikCord/exceptions.py @@ -26,24 +26,6 @@ def __init__(self, data: Dict): "\n".join(f"{e.path} - {e.code} - {e.message}" for e in self.errors_list) ) - # def extract_errors(self, d, key_path=None): - # """Get _errors key from the given dictionary.""" - # if key_path is None: - # key_path = [] - - # if d.get("_errors"): - # return [ - # LocatedError(**error, path=".".join(key_path[1:])) - # for error in d.get("_errors", []) - # ] - - # return [ - # x - # for k, v in d.items() - # if isinstance(v, dict) - # for x in self.extract_errors(v, key_path + [k]) - # ] - def extract_errors(self, d, key_path=None): """Get _errors key from the given dictionary.""" if key_path is None: From 0d86dd3ca7e7f4455ae38457fca4c87be823e3a3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 28 Jan 2023 20:43:26 +0000 Subject: [PATCH 108/335] :art: Format code automatically --- EpikCord/exceptions.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/EpikCord/exceptions.py b/EpikCord/exceptions.py index 09053503..2a358eb5 100644 --- a/EpikCord/exceptions.py +++ b/EpikCord/exceptions.py @@ -7,6 +7,7 @@ class EpikCordException(Exception): ... + @dataclass class LocatedError: code: str @@ -23,7 +24,9 @@ def __init__(self, data: Dict): self.errors_list = self.extract_errors(self.errors) super().__init__( - "\n".join(f"{e.path} - {e.code} - {e.message}" for e in self.errors_list) + "\n".join( + f"{e.path} - {e.code} - {e.message}" for e in self.errors_list + ) ) # def extract_errors(self, d, key_path=None): @@ -51,7 +54,13 @@ def extract_errors(self, d, key_path=None): if d.get("_errors"): return [ - LocatedError(**error, path=".".join(f'{k}[{k}]' if isinstance(k, int) else k for k in key_path[1:])) + LocatedError( + **error, + path=".".join( + f"{k}[{k}]" if isinstance(k, int) else k + for k in key_path[1:] + ), + ) for error in d.get("_errors", []) ] From ce6bf78eddf59b86d03b05c5e507a03bb4421426 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Mon, 30 Jan 2023 19:52:14 +0000 Subject: [PATCH 109/335] Change name of rate_limit_handling_tools to be more concise --- EpikCord/client/http.py | 2 +- .../{rate_limit_handling_tools.py => rate_limit_tools.py} | 0 EpikCord/client/websocket.py | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename EpikCord/client/{rate_limit_handling_tools.py => rate_limit_tools.py} (100%) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 3fcc3c74..4c3f75cc 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -24,7 +24,7 @@ json_serialize, log_request, ) -from .rate_limit_handling_tools import ( +from .rate_limit_tools import ( Bucket, MajorParameters, MockBucket, diff --git a/EpikCord/client/rate_limit_handling_tools.py b/EpikCord/client/rate_limit_tools.py similarity index 100% rename from EpikCord/client/rate_limit_handling_tools.py rename to EpikCord/client/rate_limit_tools.py diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index a633463a..f76837f9 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -16,7 +16,7 @@ from ..flags import Intents from ..presence import Presence from ..utils import AsyncFunction, IdentifyCommand, OpCode -from .rate_limit_handling_tools import GatewayRateLimiter +from .rate_limit_tools import GatewayRateLimiter _ORJSON = find_spec("orjson") From fbf901250da26f608b9ccde2dece6674f86bea43 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Mon, 30 Jan 2023 19:54:39 +0000 Subject: [PATCH 110/335] Move IntEnums to enums.py --- EpikCord/utils/enums.py | 70 +++++++++++++++++++++++++++++++++++++++ EpikCord/utils/types.py | 72 +---------------------------------------- 2 files changed, 71 insertions(+), 71 deletions(-) create mode 100644 EpikCord/utils/enums.py diff --git a/EpikCord/utils/enums.py b/EpikCord/utils/enums.py new file mode 100644 index 00000000..192549d0 --- /dev/null +++ b/EpikCord/utils/enums.py @@ -0,0 +1,70 @@ +from enum import IntEnum + + +class OpCode(IntEnum): + """The opcodes used in the Discord Gateway.""" + + DISPATCH = 0 + HEARTBEAT = 1 + IDENTIFY = 2 + STATUS_UPDATE = 3 + VOICE_STATE_UPDATE = 4 + VOICE_SERVER_PING = 5 + RESUME = 6 + RECONNECT = 7 + REQUEST_GUILD_MEMBERS = 8 + INVALID_SESSION = 9 + HELLO = 10 + HEARTBEAT_ACK = 11 + + +class GatewayCloseCode(IntEnum): + """The close codes used in the Discord Gateway.""" + + UNKNOWN_ERROR = 4000 + UNKNOWN_OPCODE = 4001 + DECODE_ERROR = 4002 + NOT_AUTHENTICATED = 4003 + AUTHENTICATION_FAILED = 4004 + ALREADY_AUTHENTICATED = 4005 + INVALID_SEQUENCE = 4007 + RATE_LIMITED = 4008 + SESSION_TIMED_OUT = 4009 + INVALID_SHARD = 4010 + SHARDING_REQUIRED = 4011 + INVALID_API_VERSION = 4012 + INVALID_INTENTS = 4013 + DISALLOWED_INTENTS = 4014 + + +class VoiceOpCode(IntEnum): + """The opcodes used in the Discord Voice WebSocket connection.""" + + IDENTIFY = 0 + SELECT_PROTOCOL = 1 + READY = 2 + HEARTBEAT = 3 + SESSION_DESCRIPTION = 4 + SPEAKING = 5 + HEARTBEAT_ACK = 6 + RESUME = 7 + HELLO = 8 + RESUMED = 9 + CLIENT_DISCONNECT = 13 + + +class VoiceCloseCode(IntEnum): + """The close codes used in the Discord Voice WebSocket connection.""" + + UNKNOWN_OPCODE = 4001 + FAILED_TO_DECODE_PAYLOAD = 4002 + NOT_AUTHENTICATED = 4003 + AUTHENTICATION_FAILED = 4004 + ALREADY_AUTHENTICATED = 4005 + SESSION_NO_LONGER_VALID = 4006 + SESSION_TIMEOUT = 4009 + SERVER_NOT_FOUND = 4011 + UNKNOWN_PROTOCOL = 4012 + DISCONNECTED = 4014 + VOICE_SERVER_CRASHED = 4015 + UNKNOWN_ENCRYPTION_MODE = 4016 diff --git a/EpikCord/utils/types.py b/EpikCord/utils/types.py index 47147071..0d57f51e 100644 --- a/EpikCord/utils/types.py +++ b/EpikCord/utils/types.py @@ -1,4 +1,3 @@ -from enum import IntEnum from typing import Any, Callable, Coroutine, TypedDict from discord_typings import IdentifyConnectionProperties @@ -9,75 +8,6 @@ AsyncFunction = Callable[..., Coroutine[Any, Any, Any]] -class OpCode(IntEnum): - """The opcodes used in the Discord Gateway.""" - - DISPATCH = 0 - HEARTBEAT = 1 - IDENTIFY = 2 - STATUS_UPDATE = 3 - VOICE_STATE_UPDATE = 4 - VOICE_SERVER_PING = 5 - RESUME = 6 - RECONNECT = 7 - REQUEST_GUILD_MEMBERS = 8 - INVALID_SESSION = 9 - HELLO = 10 - HEARTBEAT_ACK = 11 - - -class GatewayCloseCode(IntEnum): - """The close codes used in the Discord Gateway.""" - - UNKNOWN_ERROR = 4000 - UNKNOWN_OPCODE = 4001 - DECODE_ERROR = 4002 - NOT_AUTHENTICATED = 4003 - AUTHENTICATION_FAILED = 4004 - ALREADY_AUTHENTICATED = 4005 - INVALID_SEQUENCE = 4007 - RATE_LIMITED = 4008 - SESSION_TIMED_OUT = 4009 - INVALID_SHARD = 4010 - SHARDING_REQUIRED = 4011 - INVALID_API_VERSION = 4012 - INVALID_INTENTS = 4013 - DISALLOWED_INTENTS = 4014 - - -class VoiceOpCode(IntEnum): - """The opcodes used in the Discord Voice WebSocket connection.""" - - IDENTIFY = 0 - SELECT_PROTOCOL = 1 - READY = 2 - HEARTBEAT = 3 - SESSION_DESCRIPTION = 4 - SPEAKING = 5 - HEARTBEAT_ACK = 6 - RESUME = 7 - HELLO = 8 - RESUMED = 9 - CLIENT_DISCONNECT = 13 - - -class VoiceCloseCode(IntEnum): - """The close codes used in the Discord Voice WebSocket connection.""" - - UNKNOWN_OPCODE = 4001 - FAILED_TO_DECODE_PAYLOAD = 4002 - NOT_AUTHENTICATED = 4003 - AUTHENTICATION_FAILED = 4004 - ALREADY_AUTHENTICATED = 4005 - SESSION_NO_LONGER_VALID = 4006 - SESSION_TIMEOUT = 4009 - SERVER_NOT_FOUND = 4011 - UNKNOWN_PROTOCOL = 4012 - DISCONNECTED = 4014 - VOICE_SERVER_CRASHED = 4015 - UNKNOWN_ENCRYPTION_MODE = 4016 - - class IdentifyData(TypedDict): """The data used in the identify payload.""" @@ -92,7 +22,7 @@ class IdentifyData(TypedDict): class IdentifyCommand(TypedDict): """The data used to identify with the gateway.""" - op: Literal[OpCode.IDENTIFY] + op: Literal[2] d: IdentifyData From 984980cc4b7e68762f3f82468fd0ab180cebac54 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Mon, 30 Jan 2023 19:55:21 +0000 Subject: [PATCH 111/335] Export enums --- EpikCord/utils/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py index 14eba95e..9bb578ac 100644 --- a/EpikCord/utils/__init__.py +++ b/EpikCord/utils/__init__.py @@ -11,11 +11,14 @@ ) from .types import ( AsyncFunction, - GatewayCloseCode, IdentifyCommand, IdentifyData, - OpCode, SendingAttachmentData, +) + +from .enums import ( + GatewayCloseCode, + OpCode, VoiceCloseCode, VoiceOpCode, ) From d90415254c123bb2f19c05967292397270611c1a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 30 Jan 2023 19:55:51 +0000 Subject: [PATCH 112/335] :art: Format code automatically --- EpikCord/utils/__init__.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py index 9bb578ac..7ccf34ed 100644 --- a/EpikCord/utils/__init__.py +++ b/EpikCord/utils/__init__.py @@ -1,3 +1,4 @@ +from .enums import GatewayCloseCode, OpCode, VoiceCloseCode, VoiceOpCode from .loose import ( add_file, cancel_tasks, @@ -16,13 +17,6 @@ SendingAttachmentData, ) -from .enums import ( - GatewayCloseCode, - OpCode, - VoiceCloseCode, - VoiceOpCode, -) - __all__ = ( "AsyncFunction", "GatewayCloseCode", From 189586b31fc801bd37534da532fdecda2a11390e Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Mon, 30 Jan 2023 20:09:25 +0000 Subject: [PATCH 113/335] Update Flags --- EpikCord/flags.py | 139 ++++++++++++++++++++++------------------------ 1 file changed, 66 insertions(+), 73 deletions(-) diff --git a/EpikCord/flags.py b/EpikCord/flags.py index 63af8774..3a6cdeaf 100644 --- a/EpikCord/flags.py +++ b/EpikCord/flags.py @@ -49,82 +49,75 @@ def all(cls): class Intents(Flag): - guilds = 1 << 0 - members = 1 << 1 - bans = 1 << 2 - emojis_and_stickers = 1 << 3 - integrations = 1 << 4 - webhooks = 1 << 5 - invites = 1 << 6 - voice_states = 1 << 7 - presences = 1 << 8 - - guild_messages = 1 << 9 - guild_message_reactions = 1 << 10 - guild_message_typing = 1 << 11 - - direct_messages = 1 << 12 - direct_message_reactions = 1 << 13 - direct_message_typing = 1 << 14 - - message_content = 1 << 15 - scheduled_event = 1 << 16 - - auto_moderation_configuration = 1 << 20 - auto_moderation_action_execution = 1 << 21 - + GUILDS = 1 << 0 + GUILD_MEMBERS = 1 << 1 + GUILD_MODERATION = 1 << 2 + GUILD_EMOJIS_AND_STICKERS = 1 << 3 + GUILD_INTEGRATIONS = 1 << 4 + GUILD_WEBHOOKS = 1 << 5 + GUILD_INVITES = 1 << 6 + GUILD_VOICE_STATES = 1 << 7 + GUILD_PRESENCES = 1 << 8 + GUILD_MESSAGES = 1 << 9 + GUILD_MESSAGE_REACTIONS = 1 << 10 + GUILD_MESSAGE_TYPING = 1 << 11 + DIRECT_MESSAGES = 1 << 12 + DIRECT_MESSAGE_REACTIONS = 1 << 13 + DIRECT_MESSAGE_TYPING = 1 << 14 + MESSAGE_CONTENT = 1 << 15 + GUILD_SCHEDULED_EVENTS = 1 << 16 + AUTO_MODERATION_CONFIGURATION = 1 << 20 + AUTO_MODERATION_EXECUTION = 1 << 21 class SystemChannelFlags(Flag): - - suppress_join_notifications = 1 << 0 - suppress_premium_subscriptions = 1 << 1 - suppress_guild_reminder_notifications = 1 << 2 - suppress_join_notification_replies = 1 << 3 - - + SUPPRESS_JOIN_NOTIFICATIONS = 1 << 0 + SUPPRESS_PREMIUM_SUBSCRIPTIONS = 1 << 1 + SUPPRESS_GUILD_REMINDER_NOTIFICATIONS = 1 << 2 + SUPPRESS_JOIN_NOTIFICATION_REPLIES = 1 << 3 + SUPPRESS_ROLE_SUBSCRIPTION_PURCHASE_NOTIFICATIONS = 1 << 4 + SUPPRESS_ROLE_SUBSCRIPTION_PURCHASE_NOTIFICATION_REPLIES = 1 << 5 class Permissions(Flag): - create_instant_invite = 1 << 0 - kick_members = 1 << 1 - ban_members = 1 << 2 - administrator = 1 << 3 - manage_channels = 1 << 4 - manage_guild = 1 << 5 - add_reactions = 1 << 6 - view_audit_log = 1 << 7 - priority_speaker = 1 << 8 - stream = 1 << 9 - read_messages = 1 << 10 - send_messages = 1 << 11 - send_tts_messages = 1 << 12 - manage_messages = 1 << 13 - embed_links = 1 << 14 - attach_files = 1 << 15 - read_message_history = 1 << 16 - mention_everyone = 1 << 17 - use_external_emojis = 1 << 18 - connect = 1 << 20 - speak = 1 << 21 - mute_members = 1 << 22 - deafen_members = 1 << 23 - move_members = 1 << 24 - use_voice_activation = 1 << 25 - change_nickname = 1 << 26 - manage_nicknames = 1 << 27 - manage_roles = 1 << 28 - manage_webhooks = 1 << 29 - manage_emojis_and_stickers = 1 << 30 - use_application_commands = 1 << 31 - request_to_speak = 1 << 32 - manage_events = 1 << 33 - manage_threads = 1 << 34 - create_public_threads = 1 << 35 - create_private_threads = 1 << 36 - use_external_stickers = 1 << 37 - send_messages_in_threads = 1 << 38 - start_embedded_activities = 1 << 39 - moderator_members = 1 << 40 + CREATE_INSTANT_INVITE = 1 << 0 + KICK_MEMBERS = 1 << 1 + BAN_MEMBERS = 1 << 2 + ADMINISTRATOR = 1 << 3 + MANAGE_CHANNELS = 1 << 4 + MANAGE_GUILD = 1 << 5 + ADD_REACTIONS = 1 << 6 + VIEW_AUDIT_LOG = 1 << 7 + PRIORITY_SPEAKER = 1 << 8 + STREAM = 1 << 9 + READ_MESSAGES = 1 << 10 + SEND_MESSAGES = 1 << 11 + SEND_TTS_MESSAGES = 1 << 12 + MANAGE_MESSAGES = 1 << 13 + EMBED_LINKS = 1 << 14 + ATTACH_FILES = 1 << 15 + MENTION_EVERYONE = 1 << 17 + USE_EXTERNAL_EMOJIS = 1 << 18 + CONNECT = 1 << 20 + SPEAK = 1 << 21 + MUTE_MEMBERS = 1 << 22 + DEAFEN_MEMBERS = 1 << 23 + MOVE_MEMBERS = 1 << 24 + USE_VOICE_ACTIVATION = 1 << 25 + CHANGE_NICKNAME = 1 << 26 + MANAGE_NICKNAMES = 1 << 27 + MANAGE_ROLES = 1 << 28 + MANAGE_WEBHOOKS = 1 << 29 + MANAGE_EMOJIS_AND_STICKERS = 1 << 30 + USE_APPLICATION_COMMANDS = 1 << 31 + REQUEST_TO_SPEAK = 1 << 32 + MANAGE_EVENTS = 1 << 33 + MANAGE_THREADS = 1 << 34 + CREATE_PUBLIC_THREADS = 1 << 35 + CREATE_PRIVATE_THREADS = 1 << 36 + USE_EXTERNAL_STICKERS = 1 << 37 + SEND_MESSAGES_IN_THREADS = 1 << 38 + START_EMBEDDED_ACTIVITIES = 1 << 39 + MODERATOR_MEMBERS = 1 << 40 class ChannelFlags(Flag): - pinned = 1 << 1 - require_tag = 1 << 4 + PINNED = 1 << 1 + REQUIRE_TAG = 1 << 4 \ No newline at end of file From 073cc6ccd6471f7b267f9da42ce407a79a51d5e7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 30 Jan 2023 20:10:01 +0000 Subject: [PATCH 114/335] :art: Format code automatically --- EpikCord/flags.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/EpikCord/flags.py b/EpikCord/flags.py index 3a6cdeaf..98faf089 100644 --- a/EpikCord/flags.py +++ b/EpikCord/flags.py @@ -69,13 +69,16 @@ class Intents(Flag): AUTO_MODERATION_CONFIGURATION = 1 << 20 AUTO_MODERATION_EXECUTION = 1 << 21 + class SystemChannelFlags(Flag): - SUPPRESS_JOIN_NOTIFICATIONS = 1 << 0 + SUPPRESS_JOIN_NOTIFICATIONS = 1 << 0 SUPPRESS_PREMIUM_SUBSCRIPTIONS = 1 << 1 SUPPRESS_GUILD_REMINDER_NOTIFICATIONS = 1 << 2 - SUPPRESS_JOIN_NOTIFICATION_REPLIES = 1 << 3 + SUPPRESS_JOIN_NOTIFICATION_REPLIES = 1 << 3 SUPPRESS_ROLE_SUBSCRIPTION_PURCHASE_NOTIFICATIONS = 1 << 4 SUPPRESS_ROLE_SUBSCRIPTION_PURCHASE_NOTIFICATION_REPLIES = 1 << 5 + + class Permissions(Flag): CREATE_INSTANT_INVITE = 1 << 0 KICK_MEMBERS = 1 << 1 @@ -120,4 +123,4 @@ class Permissions(Flag): class ChannelFlags(Flag): PINNED = 1 << 1 - REQUIRE_TAG = 1 << 4 \ No newline at end of file + REQUIRE_TAG = 1 << 4 From 7cd645f60d7d4d82e69f3ed977adfbe9e3a649a5 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Mon, 30 Jan 2023 20:28:58 +0000 Subject: [PATCH 115/335] Add exceptions for Gateway Closures --- EpikCord/client/http.py | 1 + EpikCord/client/websocket.py | 1 + EpikCord/client/ws_close_handler.py | 80 +++++++++++++++++++++++++++++ EpikCord/exceptions.py | 18 +++++-- EpikCord/utils/enums.py | 1 + EpikCord/utils/types.py | 3 +- 6 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 EpikCord/client/ws_close_handler.py diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 4c3f75cc..0924b560 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -244,6 +244,7 @@ async def handle_ratelimit( bucket: Union[Bucket, TopLevelBucket, MockBucket] The bucket for the request. """ + logger.critical(f"Ratelimited bucket {bucket} for {data['retry_after']} seconds.") bucket.clear() if data["global"]: diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index f76837f9..ed84fd99 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -264,6 +264,7 @@ async def connect(self): async for message in self.ws: logger.debug("Received message: %s", message.json()) await self.event_handler.handle(message) # type: ignore + print(self.ws.close_code) async def handle_close(self): ... diff --git a/EpikCord/client/ws_close_handler.py b/EpikCord/client/ws_close_handler.py new file mode 100644 index 00000000..1f5e877e --- /dev/null +++ b/EpikCord/client/ws_close_handler.py @@ -0,0 +1,80 @@ +import logging +from typing import Dict, Optional, Type + +from ..utils import GatewayCloseCode +from ..exceptions import ( + DisallowedIntents, + InvalidIntents, + InvalidToken, + GatewayRateLimited, + ShardingRequired, +) + +logger = logging.getLogger("EpikCord.websocket") + + +class CloseHandler: + def __init__(self, resumable: bool = True): + self.resumable = resumable + + +class CloseHandlerRaise(CloseHandler): + def __init__(self, exception: Type[Exception], message: Optional[str] = None): + super().__init__(resumable=False) + self.exception = exception + self.message = message + + +class CloseHandlerLog(CloseHandler): + def __init__(self, message: str, resumable: bool = True, need_report: bool = False): + super().__init__(resumable=resumable) + self.message = message + self.need_report = need_report + + +ignore = CloseHandler() +close_dispatcher: Dict[GatewayCloseCode, CloseHandler] = { + GatewayCloseCode.ABNORMAL_CLOSURE: ignore, + GatewayCloseCode.UNKNOWN_ERROR: ignore, + GatewayCloseCode.DISALLOWED_INTENTS: CloseHandlerRaise( + DisallowedIntents, + "You cannot use privileged intents with this token, go to " + "the developer portal and allow the privileged intents " + "needed. ", + ), + GatewayCloseCode.AUTHENTICATION_FAILED: CloseHandlerRaise( + InvalidToken, "The token you provided is invalid." + ), + GatewayCloseCode.RATE_LIMITED: CloseHandlerRaise( + GatewayRateLimited, "You've been rate limited. Try again in a few minutes." + ), + GatewayCloseCode.SHARDING_REQUIRED: CloseHandlerRaise( + ShardingRequired, "You need to shard the bot." + ), + GatewayCloseCode.INVALID_API_VERSION: CloseHandlerRaise( + DeprecationWarning, + "The gateway you're connecting to is deprecated and does not " + "work, update EpikCord.", + ), + GatewayCloseCode.INVALID_INTENTS: CloseHandlerRaise( + InvalidIntents, "The intents you provided are invalid." + ), + GatewayCloseCode.UNKNOWN_OPCODE: CloseHandlerLog( + "EpikCord.py sent an invalid OPCODE to the Gateway. ", need_report=True + ), + GatewayCloseCode.DECODE_ERROR: CloseHandlerLog( + "EpikCord.py sent an invalid payload to the Gateway.", need_report=True + ), + GatewayCloseCode.NOT_AUTHENTICATED: CloseHandlerLog( + "EpikCord.py has sent a payload prior to identifying.", + resumable=False, + need_report=True, + ), + GatewayCloseCode.ALREADY_AUTHENTICATED: CloseHandlerLog( + "EpikCord.py tried to authenticate again.", need_report=True + ), + GatewayCloseCode.INVALID_SEQUENCE: CloseHandlerLog( + "EpikCord.py sent an invalid sequence number.", need_report=True + ), + GatewayCloseCode.SESSION_TIMED_OUT: CloseHandlerLog("Session timed out."), +} \ No newline at end of file diff --git a/EpikCord/exceptions.py b/EpikCord/exceptions.py index c2770ee6..49492b1f 100644 --- a/EpikCord/exceptions.py +++ b/EpikCord/exceptions.py @@ -1,9 +1,6 @@ from dataclasses import dataclass from typing import Dict -import aiohttp - - class EpikCordException(Exception): ... @@ -72,3 +69,18 @@ class BadRequest(HTTPException): class TooManyRetries(EpikCordException): ... + +class DisallowedIntents(EpikCordException): + ... + +class InvalidIntents(EpikCordException): + ... + +class InvalidToken(EpikCordException): + ... + +class GatewayRateLimited(EpikCordException): + ... + +class ShardingRequired(EpikCordException): + ... \ No newline at end of file diff --git a/EpikCord/utils/enums.py b/EpikCord/utils/enums.py index 192549d0..aef5a70a 100644 --- a/EpikCord/utils/enums.py +++ b/EpikCord/utils/enums.py @@ -21,6 +21,7 @@ class OpCode(IntEnum): class GatewayCloseCode(IntEnum): """The close codes used in the Discord Gateway.""" + ABNORMAL_CLOSURE = 1006 UNKNOWN_ERROR = 4000 UNKNOWN_OPCODE = 4001 DECODE_ERROR = 4002 diff --git a/EpikCord/utils/types.py b/EpikCord/utils/types.py index 0d57f51e..224cac73 100644 --- a/EpikCord/utils/types.py +++ b/EpikCord/utils/types.py @@ -3,6 +3,7 @@ from discord_typings import IdentifyConnectionProperties from typing_extensions import Literal, NotRequired +from .enums import OpCode from ..presence import UpdatePresenceData AsyncFunction = Callable[..., Coroutine[Any, Any, Any]] @@ -22,7 +23,7 @@ class IdentifyData(TypedDict): class IdentifyCommand(TypedDict): """The data used to identify with the gateway.""" - op: Literal[2] + op: Literal[OpCode.IDENTIFY] d: IdentifyData From 0bb4fc0c1ea406e6bda0aab01bcdd6bcba4d8476 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 30 Jan 2023 20:29:38 +0000 Subject: [PATCH 116/335] :art: Format code automatically --- EpikCord/client/http.py | 4 +++- EpikCord/client/ws_close_handler.py | 17 +++++++++++------ EpikCord/exceptions.py | 8 +++++++- EpikCord/utils/types.py | 2 +- 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 0924b560..b0993f74 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -244,7 +244,9 @@ async def handle_ratelimit( bucket: Union[Bucket, TopLevelBucket, MockBucket] The bucket for the request. """ - logger.critical(f"Ratelimited bucket {bucket} for {data['retry_after']} seconds.") + logger.critical( + f"Ratelimited bucket {bucket} for {data['retry_after']} seconds." + ) bucket.clear() if data["global"]: diff --git a/EpikCord/client/ws_close_handler.py b/EpikCord/client/ws_close_handler.py index 1f5e877e..e2e6d97d 100644 --- a/EpikCord/client/ws_close_handler.py +++ b/EpikCord/client/ws_close_handler.py @@ -1,14 +1,14 @@ import logging from typing import Dict, Optional, Type -from ..utils import GatewayCloseCode from ..exceptions import ( DisallowedIntents, + GatewayRateLimited, InvalidIntents, InvalidToken, - GatewayRateLimited, ShardingRequired, ) +from ..utils import GatewayCloseCode logger = logging.getLogger("EpikCord.websocket") @@ -19,14 +19,18 @@ def __init__(self, resumable: bool = True): class CloseHandlerRaise(CloseHandler): - def __init__(self, exception: Type[Exception], message: Optional[str] = None): + def __init__( + self, exception: Type[Exception], message: Optional[str] = None + ): super().__init__(resumable=False) self.exception = exception self.message = message class CloseHandlerLog(CloseHandler): - def __init__(self, message: str, resumable: bool = True, need_report: bool = False): + def __init__( + self, message: str, resumable: bool = True, need_report: bool = False + ): super().__init__(resumable=resumable) self.message = message self.need_report = need_report @@ -46,7 +50,8 @@ def __init__(self, message: str, resumable: bool = True, need_report: bool = Fal InvalidToken, "The token you provided is invalid." ), GatewayCloseCode.RATE_LIMITED: CloseHandlerRaise( - GatewayRateLimited, "You've been rate limited. Try again in a few minutes." + GatewayRateLimited, + "You've been rate limited. Try again in a few minutes.", ), GatewayCloseCode.SHARDING_REQUIRED: CloseHandlerRaise( ShardingRequired, "You need to shard the bot." @@ -77,4 +82,4 @@ def __init__(self, message: str, resumable: bool = True, need_report: bool = Fal "EpikCord.py sent an invalid sequence number.", need_report=True ), GatewayCloseCode.SESSION_TIMED_OUT: CloseHandlerLog("Session timed out."), -} \ No newline at end of file +} diff --git a/EpikCord/exceptions.py b/EpikCord/exceptions.py index 49492b1f..0540f642 100644 --- a/EpikCord/exceptions.py +++ b/EpikCord/exceptions.py @@ -1,6 +1,7 @@ from dataclasses import dataclass from typing import Dict + class EpikCordException(Exception): ... @@ -70,17 +71,22 @@ class BadRequest(HTTPException): class TooManyRetries(EpikCordException): ... + class DisallowedIntents(EpikCordException): ... + class InvalidIntents(EpikCordException): ... + class InvalidToken(EpikCordException): ... + class GatewayRateLimited(EpikCordException): ... + class ShardingRequired(EpikCordException): - ... \ No newline at end of file + ... diff --git a/EpikCord/utils/types.py b/EpikCord/utils/types.py index 224cac73..791bec02 100644 --- a/EpikCord/utils/types.py +++ b/EpikCord/utils/types.py @@ -3,8 +3,8 @@ from discord_typings import IdentifyConnectionProperties from typing_extensions import Literal, NotRequired -from .enums import OpCode from ..presence import UpdatePresenceData +from .enums import OpCode AsyncFunction = Callable[..., Coroutine[Any, Any, Any]] From 41121d1230f743c3dc79fb83520ad1308f60f357 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Mon, 30 Jan 2023 20:30:21 +0000 Subject: [PATCH 117/335] Format with Black --- EpikCord/client/http.py | 4 +++- EpikCord/client/ws_close_handler.py | 13 +++++++++---- EpikCord/exceptions.py | 8 +++++++- tests/e2e/test_bot_instantiation.py | 6 +----- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 0924b560..b0993f74 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -244,7 +244,9 @@ async def handle_ratelimit( bucket: Union[Bucket, TopLevelBucket, MockBucket] The bucket for the request. """ - logger.critical(f"Ratelimited bucket {bucket} for {data['retry_after']} seconds.") + logger.critical( + f"Ratelimited bucket {bucket} for {data['retry_after']} seconds." + ) bucket.clear() if data["global"]: diff --git a/EpikCord/client/ws_close_handler.py b/EpikCord/client/ws_close_handler.py index 1f5e877e..ad90b7c9 100644 --- a/EpikCord/client/ws_close_handler.py +++ b/EpikCord/client/ws_close_handler.py @@ -19,14 +19,18 @@ def __init__(self, resumable: bool = True): class CloseHandlerRaise(CloseHandler): - def __init__(self, exception: Type[Exception], message: Optional[str] = None): + def __init__( + self, exception: Type[Exception], message: Optional[str] = None + ): super().__init__(resumable=False) self.exception = exception self.message = message class CloseHandlerLog(CloseHandler): - def __init__(self, message: str, resumable: bool = True, need_report: bool = False): + def __init__( + self, message: str, resumable: bool = True, need_report: bool = False + ): super().__init__(resumable=resumable) self.message = message self.need_report = need_report @@ -46,7 +50,8 @@ def __init__(self, message: str, resumable: bool = True, need_report: bool = Fal InvalidToken, "The token you provided is invalid." ), GatewayCloseCode.RATE_LIMITED: CloseHandlerRaise( - GatewayRateLimited, "You've been rate limited. Try again in a few minutes." + GatewayRateLimited, + "You've been rate limited. Try again in a few minutes.", ), GatewayCloseCode.SHARDING_REQUIRED: CloseHandlerRaise( ShardingRequired, "You need to shard the bot." @@ -77,4 +82,4 @@ def __init__(self, message: str, resumable: bool = True, need_report: bool = Fal "EpikCord.py sent an invalid sequence number.", need_report=True ), GatewayCloseCode.SESSION_TIMED_OUT: CloseHandlerLog("Session timed out."), -} \ No newline at end of file +} diff --git a/EpikCord/exceptions.py b/EpikCord/exceptions.py index 49492b1f..0540f642 100644 --- a/EpikCord/exceptions.py +++ b/EpikCord/exceptions.py @@ -1,6 +1,7 @@ from dataclasses import dataclass from typing import Dict + class EpikCordException(Exception): ... @@ -70,17 +71,22 @@ class BadRequest(HTTPException): class TooManyRetries(EpikCordException): ... + class DisallowedIntents(EpikCordException): ... + class InvalidIntents(EpikCordException): ... + class InvalidToken(EpikCordException): ... + class GatewayRateLimited(EpikCordException): ... + class ShardingRequired(EpikCordException): - ... \ No newline at end of file + ... diff --git a/tests/e2e/test_bot_instantiation.py b/tests/e2e/test_bot_instantiation.py index eb62878c..e4f33fae 100644 --- a/tests/e2e/test_bot_instantiation.py +++ b/tests/e2e/test_bot_instantiation.py @@ -11,12 +11,8 @@ class Bot(Client): - def __init__(self, token: str): - super().__init__( - token=TokenStore(token), - intents=Intents.all() - ) + super().__init__(token=TokenStore(token), intents=Intents.all()) def test_bot_instantiation(): From 65a82127824d2b4ef541c2de8438ddad5bb83afc Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Mon, 30 Jan 2023 21:33:54 +0100 Subject: [PATCH 118/335] :bug: Fix computed value for flag class --- EpikCord/flags.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/EpikCord/flags.py b/EpikCord/flags.py index 98faf089..82dafacc 100644 --- a/EpikCord/flags.py +++ b/EpikCord/flags.py @@ -12,27 +12,26 @@ def __init_subclass__(cls) -> None: } def __init__(self, value: int = 0, **kwargs): - self.value = value self.turned_on: List[str] = [k for k, a in kwargs.items() if a] for k, v in self.class_flags.items(): if v & value and k not in self.turned_on: self.turned_on.append(k) - self.calculate_from_turned() - - def calculate_from_turned(self): - value = 0 - for key, flag in self.class_flags.items(): - if key in self.class_flags: - value |= flag - self.value = value + @property + def value(self) -> int: + return sum( + flag for key, flag in self.class_flags.items() + if key in self.turned_on + ) def __getattribute__(self, __name: str) -> Any: original = super().__getattribute__ - if __name in original("class_flags"): - return __name in original("turned_on") - return original(__name) + key = type(self).class_flags.get(__name) + + if key is None: + return original(__name) + return __name in original("turned_on") def __setattr__(self, __name: str, __value: Any) -> None: if __name not in self.class_flags: @@ -41,7 +40,6 @@ def __setattr__(self, __name: str, __value: Any) -> None: self.turned_on.append(__name) elif not __value and __name in self.turned_on: self.turned_on.remove(__name) - self.calculate_from_turned() @classmethod def all(cls): From c616b2d6592d4d29e28d3c7bc0d08de0bd90523c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 30 Jan 2023 20:34:26 +0000 Subject: [PATCH 119/335] :art: Format code automatically --- EpikCord/flags.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/EpikCord/flags.py b/EpikCord/flags.py index 82dafacc..d6aa06ff 100644 --- a/EpikCord/flags.py +++ b/EpikCord/flags.py @@ -21,7 +21,8 @@ def __init__(self, value: int = 0, **kwargs): @property def value(self) -> int: return sum( - flag for key, flag in self.class_flags.items() + flag + for key, flag in self.class_flags.items() if key in self.turned_on ) From 33ad0f27963d5bd80f49bddfbe2730ae8a10ae93 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Mon, 30 Jan 2023 20:34:50 +0000 Subject: [PATCH 120/335] Add handling for closing of WebSocket --- EpikCord/client/websocket.py | 35 ++++++++++++++++++++++++++++++++--- EpikCord/exceptions.py | 13 ++++++++----- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index ed84fd99..923eb175 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -13,10 +13,14 @@ import aiohttp from discord_typings import HelloData +from ..exceptions import ( + ClosedWebSocketConnection +) from ..flags import Intents from ..presence import Presence -from ..utils import AsyncFunction, IdentifyCommand, OpCode +from ..utils import AsyncFunction, IdentifyCommand, OpCode, GatewayCloseCode from .rate_limit_tools import GatewayRateLimiter +from .ws_close_handler import CloseHandlerRaise, CloseHandlerLog, close_dispatcher _ORJSON = find_spec("orjson") @@ -186,6 +190,8 @@ async def handle(self, message: DiscordWSMessage): if event["op"] != OpCode.DISPATCH: await self.event_mapping[event["op"]](event["d"]) + async def resume(self): + ... class DiscordWSMessage: def __init__(self, *, data, type, extra): @@ -264,7 +270,30 @@ async def connect(self): async for message in self.ws: logger.debug("Received message: %s", message.json()) await self.event_handler.handle(message) # type: ignore - print(self.ws.close_code) + await self.handle_close() async def handle_close(self): - ... + if not self.ws: + logger.debug("No websocket to handle close for.") + return + + close_code = self.ws.close_code + + try: + gce_code = GatewayCloseCode(close_code) + ch_ins = close_dispatcher[gce_code] + + except (ValueError, KeyError) as e: + raise ClosedWebSocketConnection( + f"Connection has been closed with code {self.ws.close_code}" + ) from e + + if isinstance(ch_ins, CloseHandlerRaise): + raise ch_ins.exception(ch_ins.message) + + if isinstance(ch_ins, CloseHandlerLog): + report_msg = "\n\nReport this immediately" * ch_ins.need_report + logger.critical(ch_ins.message + report_msg) + + if ch_ins.resumable: + await self.event_handler.resume() \ No newline at end of file diff --git a/EpikCord/exceptions.py b/EpikCord/exceptions.py index 0540f642..9bea5e2c 100644 --- a/EpikCord/exceptions.py +++ b/EpikCord/exceptions.py @@ -72,21 +72,24 @@ class TooManyRetries(EpikCordException): ... -class DisallowedIntents(EpikCordException): +class ClosedWebSocketConnection(EpikCordException): + ... + +class DisallowedIntents(ClosedWebSocketConnection): ... -class InvalidIntents(EpikCordException): +class InvalidIntents(ClosedWebSocketConnection): ... -class InvalidToken(EpikCordException): +class InvalidToken(ClosedWebSocketConnection): ... -class GatewayRateLimited(EpikCordException): +class GatewayRateLimited(ClosedWebSocketConnection): ... -class ShardingRequired(EpikCordException): +class ShardingRequired(ClosedWebSocketConnection): ... From 2fc0080b8ffb5a190a2ab74d7bbf21a18778cd01 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 30 Jan 2023 20:36:00 +0000 Subject: [PATCH 121/335] :art: Format code automatically --- EpikCord/client/websocket.py | 15 +++++++++------ EpikCord/exceptions.py | 1 + 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 923eb175..66f1f37e 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -13,14 +13,16 @@ import aiohttp from discord_typings import HelloData -from ..exceptions import ( - ClosedWebSocketConnection -) +from ..exceptions import ClosedWebSocketConnection from ..flags import Intents from ..presence import Presence -from ..utils import AsyncFunction, IdentifyCommand, OpCode, GatewayCloseCode +from ..utils import AsyncFunction, GatewayCloseCode, IdentifyCommand, OpCode from .rate_limit_tools import GatewayRateLimiter -from .ws_close_handler import CloseHandlerRaise, CloseHandlerLog, close_dispatcher +from .ws_close_handler import ( + CloseHandlerLog, + CloseHandlerRaise, + close_dispatcher, +) _ORJSON = find_spec("orjson") @@ -193,6 +195,7 @@ async def handle(self, message: DiscordWSMessage): async def resume(self): ... + class DiscordWSMessage: def __init__(self, *, data, type, extra): self.data = data @@ -296,4 +299,4 @@ async def handle_close(self): logger.critical(ch_ins.message + report_msg) if ch_ins.resumable: - await self.event_handler.resume() \ No newline at end of file + await self.event_handler.resume() diff --git a/EpikCord/exceptions.py b/EpikCord/exceptions.py index 9bea5e2c..cec6523b 100644 --- a/EpikCord/exceptions.py +++ b/EpikCord/exceptions.py @@ -75,6 +75,7 @@ class TooManyRetries(EpikCordException): class ClosedWebSocketConnection(EpikCordException): ... + class DisallowedIntents(ClosedWebSocketConnection): ... From 56a8852f3c563f73a1e3ad73353769b9c2305ac1 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Mon, 30 Jan 2023 20:37:54 +0000 Subject: [PATCH 122/335] Set Authorization header to string --- EpikCord/client/http.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index b0993f74..57b8edfa 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -88,7 +88,7 @@ def __init__( self.version: APIVersion = version self.session: aiohttp.ClientSession = aiohttp.ClientSession( headers={ - "Authorization": f"Bot {self.token}", + "Authorization": f"Bot {self.token.value}", "User-Agent": ( "DiscordBot " f"(https://github.com/EpikCord/EpikCord.py {__version__})" From 8f7767aa1d513eb7f11d11c24769c41d24d9ece3 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 21:41:21 +0100 Subject: [PATCH 123/335] :white_check_mark: Add tests for clean url --- tests/unit/__init__.py | 0 tests/unit/test_clean_url.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_clean_url.py diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/test_clean_url.py b/tests/unit/test_clean_url.py new file mode 100644 index 00000000..96999317 --- /dev/null +++ b/tests/unit/test_clean_url.py @@ -0,0 +1,33 @@ +from EpikCord.utils.loose import clean_url + + +def test_clean_url(): + url = "channels/1234567890/messages/1234567890" + assert clean_url(url, 9) == ( + 'https://discord.com/api/v9/channels/' + '1234567890/messages/1234567890' + ) + + +def test_clean_url_with_slash(): + url = "/channels/1234567890/messages/1234567890" + assert clean_url(url, 9) == ( + 'https://discord.com/api/v9/channels/' + '1234567890/messages/1234567890' + ) + + +def test_clean_url_with_version(): + url = "channels/1234567890/messages/1234567890" + assert clean_url(url, 8) == ( + 'https://discord.com/api/v8/channels/' + '1234567890/messages/1234567890' + ) + + +def test_clean_url_with_double_slash(): + url = "/channels/1234567890/messages/1234567890/" + assert clean_url(url, 9) == ( + 'https://discord.com/api/v9/channels/' + '1234567890/messages/1234567890' + ) From b68f79d1398bee2e6adc623362d42985cfa26dae Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 21:50:07 +0100 Subject: [PATCH 124/335] :white_check_mark: Add tests for clear none values --- tests/unit/test_clear_none_values.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 tests/unit/test_clear_none_values.py diff --git a/tests/unit/test_clear_none_values.py b/tests/unit/test_clear_none_values.py new file mode 100644 index 00000000..27cca62c --- /dev/null +++ b/tests/unit/test_clear_none_values.py @@ -0,0 +1,16 @@ +from EpikCord.utils.loose import clear_none_values + + +def test_clear_none_values(): + data = {"a": None, "b": 1, "c": None} + assert clear_none_values(data) == {"b": 1} + + +def test_clear_none_values_empty(): + data = {} + assert clear_none_values(data) == {} + + +def test_clear_none_values_no_none(): + data = {"a": 1, "b": 1, "c": 1} + assert clear_none_values(data) == data From 3b3a52068c64e4125621a8b90c158cb119cdb470 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 21:54:54 +0100 Subject: [PATCH 125/335] :white_check_mark: Add tests for file class --- tests/unit/test_file.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 tests/unit/test_file.py diff --git a/tests/unit/test_file.py b/tests/unit/test_file.py new file mode 100644 index 00000000..bfb650ae --- /dev/null +++ b/tests/unit/test_file.py @@ -0,0 +1,19 @@ +import io + +from EpikCord.file import File + + +def test_file(): + file = File("test.txt", io.BytesIO(b"test")) + + assert file.filename == "test.txt" + assert file.mime_type == "text/plain" + assert file.description is None + + +def test_file_spoiler(): + file = File("test.txt", io.BytesIO(b"test"), spoiler=True) + + assert file.filename == "SPOILER_test.txt" + assert file.mime_type == "text/plain" + assert file.description is None From b5869088d2b3c8c5bdc9effbdd913e8aef8577fb Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 22:05:27 +0100 Subject: [PATCH 126/335] :white_check_mark: Add more tests --- tests/unit/test_intents.py | 34 ++++++++++++++++++++++++++ tests/unit/test_json_serialize.py | 40 +++++++++++++++++++++++++++++++ tests/unit/test_singleton.py | 22 +++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 tests/unit/test_intents.py create mode 100644 tests/unit/test_json_serialize.py create mode 100644 tests/unit/test_singleton.py diff --git a/tests/unit/test_intents.py b/tests/unit/test_intents.py new file mode 100644 index 00000000..1c60fe55 --- /dev/null +++ b/tests/unit/test_intents.py @@ -0,0 +1,34 @@ +from EpikCord import Intents + + +def test_intents(): + intents = Intents(guilds=True, members=True) + assert intents.guilds + assert intents.members + + assert intents.turned_on == ["guilds", "members"] + intents.guilds = False + + assert intents.turned_on == ["members"] + + intents.bans = True + assert intents.turned_on == ["members", "bans"] + + +def test_intents_none(): + intents = Intents() + assert intents.turned_on == [] + + +def test_intents_all(): + intents = Intents.all() + + for item in Intents.class_flags: + assert getattr(intents, item) + + members = list(Intents.class_flags) + assert intents.turned_on == members + intents.guilds = False + + assert intents.turned_on == members[1:] + assert not intents.guilds diff --git a/tests/unit/test_json_serialize.py b/tests/unit/test_json_serialize.py new file mode 100644 index 00000000..ae7d5a19 --- /dev/null +++ b/tests/unit/test_json_serialize.py @@ -0,0 +1,40 @@ +from EpikCord.utils import OpCode +from EpikCord.utils.loose import json_serialize + + +def test_json_serialize(): + data = {"a": 1, "b": 2} + assert json_serialize(data) == '{"a":1,"b":2}' + + +def test_json_serialize_empty(): + data = {} + assert json_serialize(data) == "{}" + + +def test_json_serialize_none(): + data = None + assert json_serialize(data) == "null" + + +def test_json_serialize_complex(): + data = { + "op": OpCode.IDENTIFY, + "d": { + "token": "token", + "intents": 1, + "properties": { + "os": "tests", + "browser": "EpikCord.py", + "device": "EpikCord.py", + }, + "compress": True, + "large_threshold": 50, + }, + } + + assert json_serialize(data) == ( + '{"op":2,"d":{"token":"token","intents":1,"properties":{"os":"tests",' + '"browser":"EpikCord.py","device":"EpikCord.py"},"compress":true,' + '"large_threshold":50}}' + ) diff --git a/tests/unit/test_singleton.py b/tests/unit/test_singleton.py new file mode 100644 index 00000000..b5f3f2d7 --- /dev/null +++ b/tests/unit/test_singleton.py @@ -0,0 +1,22 @@ +import random +import sys + +from EpikCord.utils.loose import singleton + + +class TestClass: + + def __init__(self): + self.time = random.random() + + def __hash__(self): + return self.time + + +def test_singleton(): + cls = singleton(TestClass) + count = sys.getrefcount(cls) + + objects = [cls() for _ in range(1000)] + assert all(obj is objects[0] for obj in objects[1:]) + assert sys.getrefcount(cls) == count From d36fc282582f0be1e6ab99c25de8d9f3530e7293 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sat, 28 Jan 2023 22:45:07 +0100 Subject: [PATCH 127/335] :adhesive_bandage: Fix json serializer test (i guess orjson vs json bug?) --- tests/unit/test_json_serialize.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_json_serialize.py b/tests/unit/test_json_serialize.py index ae7d5a19..2589a51e 100644 --- a/tests/unit/test_json_serialize.py +++ b/tests/unit/test_json_serialize.py @@ -4,7 +4,7 @@ def test_json_serialize(): data = {"a": 1, "b": 2} - assert json_serialize(data) == '{"a":1,"b":2}' + assert json_serialize(data).replace(' ', '') == '{"a":1,"b":2}' def test_json_serialize_empty(): @@ -33,7 +33,7 @@ def test_json_serialize_complex(): }, } - assert json_serialize(data) == ( + assert json_serialize(data).replace(' ', '') == ( '{"op":2,"d":{"token":"token","intents":1,"properties":{"os":"tests",' '"browser":"EpikCord.py","device":"EpikCord.py"},"compress":true,' '"large_threshold":50}}' From 70c7b2bb6f8061672481ab728106ad957a9d7183 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Mon, 30 Jan 2023 20:48:09 +0000 Subject: [PATCH 128/335] Remove completed wait_for Tasks --- EpikCord/client/websocket.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 66f1f37e..c7c7f463 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -172,15 +172,14 @@ async def handle(self, message: DiscordWSMessage): logger.error("Failed to decode message: %s", message.data) return - value: Optional[Union[str, int]] = None + values: List[Union[str, int]] = [] if event["op"] in self.wait_for_events: - value = event["op"] + values.append(event["op"]) + if event.get("t") and event["t"].lower() in self.wait_for_events: + values.append(event["t"].lower()) - elif event.get("t") and event["t"].lower() in self.wait_for_events: - value = event["t"].lower() - - if value: + for value in values: for wait_for_event in self.wait_for_events[value]: if wait_for_event.check: try: @@ -188,6 +187,7 @@ async def handle(self, message: DiscordWSMessage): except Exception as exception: wait_for_event.future.set_exception(exception) wait_for_event.future.set_result(event) + self.wait_for_events[value].remove(wait_for_event) if event["op"] != OpCode.DISPATCH: await self.event_mapping[event["op"]](event["d"]) From bb28c7c4cc7c5bc425a3376fdf49d0a494454862 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Mon, 30 Jan 2023 21:03:17 +0000 Subject: [PATCH 129/335] Add way to cancel tasks --- EpikCord/client/client.py | 9 +++++++++ EpikCord/client/websocket.py | 3 +++ EpikCord/ext/tasks.py | 7 ++++++- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index 97e6db9e..80bbe3c2 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -77,3 +77,12 @@ def stop_loop_on_completion(f: asyncio.Future): finally: future.remove_done_callback(stop_loop_on_completion) cleanup_loop(loop) + + async def close(self): + if not self.http.session.closed: + await self.http.session.close() + + if self.ws and self.ws.closed: + await self.ws.close() + + self.rate_limiter.reset.cancel() diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index c7c7f463..b38d314a 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -232,6 +232,9 @@ async def receive(self, *args, **kwargs): data=message, type=ws_message.type, extra=ws_message.extra ) + async def close(self, *, code: int = 4000, message: bytes = b"") -> bool: + logger.debug("Closing websocket with code %s and message %s", code, message) + return await super().close(code=code, message=message) class WebSocketClient: def __init__( diff --git a/EpikCord/ext/tasks.py b/EpikCord/ext/tasks.py index 118a98a6..4c97a7c9 100644 --- a/EpikCord/ext/tasks.py +++ b/EpikCord/ext/tasks.py @@ -15,6 +15,7 @@ def __init__( self.duration: float = duration self.max_runs: int = max_runs self.runs: int = 0 + self._task: typing.Optional[asyncio.Task] = None async def start(self, *args: typing.Any, **kwargs: typing.Any): while self.runs < self.max_runs: @@ -26,7 +27,11 @@ async def start(self, *args: typing.Any, **kwargs: typing.Any): await asyncio.sleep(self.duration) def run(self, *args: typing.Any, **kwargs: typing.Any): - return asyncio.create_task(self.start(*args, **kwargs)) + self._task = asyncio.create_task(self.start(*args, **kwargs)) + + def cancel(self): + if self._task is not None: + self._task.cancel() def task(duration: timedelta, max_runs=-1): From 92f4c43850678f069c7b602a38a68fb1877a916e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 30 Jan 2023 21:03:56 +0000 Subject: [PATCH 130/335] :art: Format code automatically --- EpikCord/client/client.py | 4 ++-- EpikCord/client/websocket.py | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index 80bbe3c2..ac56c10e 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -81,8 +81,8 @@ def stop_loop_on_completion(f: asyncio.Future): async def close(self): if not self.http.session.closed: await self.http.session.close() - + if self.ws and self.ws.closed: await self.ws.close() - + self.rate_limiter.reset.cancel() diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index b38d314a..c44b3fb0 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -233,9 +233,12 @@ async def receive(self, *args, **kwargs): ) async def close(self, *, code: int = 4000, message: bytes = b"") -> bool: - logger.debug("Closing websocket with code %s and message %s", code, message) + logger.debug( + "Closing websocket with code %s and message %s", code, message + ) return await super().close(code=code, message=message) + class WebSocketClient: def __init__( self, From 4faf55ce099d8a6ea361d78a0ecc35a328e1e3cb Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Mon, 30 Jan 2023 21:14:47 +0000 Subject: [PATCH 131/335] Remove duplicate logging --- EpikCord/client/websocket.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index c44b3fb0..5f91bf3f 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -67,10 +67,9 @@ def __init__(self, client: WebSocketClient): OpCode.HEARTBEAT_ACK: self.heartbeat_ack, } - async def heartbeat_ack(self, data: Any): + async def heartbeat_ack(self, _: Any): """Handle the heartbeat ack event. OpCode 11.""" - logger.debug("Received heartbeat ack from Gateway") - + ... def wait_for( self, *, From b5f4f951cfeb1c0764a33fbdcc18f1b785e088e2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 30 Jan 2023 21:15:29 +0000 Subject: [PATCH 132/335] :art: Format code automatically --- EpikCord/client/websocket.py | 1 + 1 file changed, 1 insertion(+) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 5f91bf3f..51049e3c 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -70,6 +70,7 @@ def __init__(self, client: WebSocketClient): async def heartbeat_ack(self, _: Any): """Handle the heartbeat ack event. OpCode 11.""" ... + def wait_for( self, *, From 88933f038dde7e4584787aad2ec2396753bd49cb Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Mon, 30 Jan 2023 21:18:32 +0000 Subject: [PATCH 133/335] Current state --- EpikCord/client/websocket.py | 1 - 1 file changed, 1 deletion(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 51049e3c..833abcb3 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -54,7 +54,6 @@ def __init__( self.future: asyncio.Future = asyncio.Future() self.check = check - class GatewayEventHandler: def __init__(self, client: WebSocketClient): self.client = client From 8d567d022ab8c7305daf44a581aeff13cd92a105 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Mon, 30 Jan 2023 21:49:36 +0000 Subject: [PATCH 134/335] Add event decorator and dispatch function --- EpikCord/client/websocket.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 833abcb3..680160d5 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -60,12 +60,26 @@ def __init__(self, client: WebSocketClient): self.wait_for_events: DefaultDict[Union[str, int], List] = defaultdict( list ) + self.events: DefaultDict[str, List[AsyncFunction]] = defaultdict(list) self.event_mapping: Dict[OpCode, AsyncFunction] = { OpCode.HELLO: self.hello, OpCode.HEARTBEAT: partial(self.heartbeat, forced=True), OpCode.HEARTBEAT_ACK: self.heartbeat_ack, } + def event(self): + """Register an event handler. This is a decorator.""" + def decorator(func: AsyncFunction): + name = func.__name__.lower().replace("on_", "") + self.events[name].append(func) + return func + return decorator + + async def dispatch(self, event_name: str, *args, **kwargs): + """Dispatch an event to all event handlers.""" + for event in self.events[event_name]: + asyncio.create_task(event(*args, **kwargs)) + async def heartbeat_ack(self, _: Any): """Handle the heartbeat ack event. OpCode 11.""" ... @@ -87,10 +101,10 @@ def wait_for( name=name, opcode=opcode, timeout=timeout, check=check ) - if name: - self.wait_for_events[name].append(event) - elif opcode: + if opcode: self.wait_for_events[opcode.value].append(event) + elif name: + self.wait_for_events[name].append(event) return asyncio.wait_for(event.future, timeout=timeout) From 50e3431ab58fb1f1fad7f338897e234e1c3b9efe Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 30 Jan 2023 21:50:15 +0000 Subject: [PATCH 135/335] :art: Format code automatically --- EpikCord/client/websocket.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 680160d5..bc8bddac 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -54,6 +54,7 @@ def __init__( self.future: asyncio.Future = asyncio.Future() self.check = check + class GatewayEventHandler: def __init__(self, client: WebSocketClient): self.client = client @@ -69,10 +70,12 @@ def __init__(self, client: WebSocketClient): def event(self): """Register an event handler. This is a decorator.""" + def decorator(func: AsyncFunction): name = func.__name__.lower().replace("on_", "") self.events[name].append(func) return func + return decorator async def dispatch(self, event_name: str, *args, **kwargs): From 2db57849b3c171bdbad9ad3845f2e08807eca2e9 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Mon, 30 Jan 2023 21:51:41 +0000 Subject: [PATCH 136/335] Add handling of events --- EpikCord/client/websocket.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 680160d5..13c80584 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -203,7 +203,12 @@ async def handle(self, message: DiscordWSMessage): self.wait_for_events[value].remove(wait_for_event) if event["op"] != OpCode.DISPATCH: - await self.event_mapping[event["op"]](event["d"]) + if event["op"] in self.event_mapping: + await self.event_mapping[event["op"]](event["d"]) + else: + logger.error("Unhandled opcode %s", event["op"]) + else: + await self.dispatch(event["t"].lower(), event["d"]) # TODO: Once we have completed the HTTP objects, we can then start to transform them before they reach the end user. async def resume(self): ... From b2645fb2533f2f000dc7ed6cecc40baef1ac94d9 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Mon, 30 Jan 2023 22:05:06 +0100 Subject: [PATCH 137/335] :memo: Fix outdated docstrings --- EpikCord/client/client.py | 18 +++---- EpikCord/client/http.py | 8 --- EpikCord/client/rate_limit_tools.py | 76 ++++++++++++++++------------- EpikCord/client/websocket.py | 2 +- EpikCord/file.py | 4 +- 5 files changed, 52 insertions(+), 56 deletions(-) diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index 97e6db9e..260054b9 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -18,6 +18,15 @@ class Client(WebSocketClient): """ The main class of EpikCord. Use this to interact with the Discord API and Gateway. + + Attributes + ---------- + token: str + The token of the bot. + intents: Intents + The intents of the bot. + http: HTTPClient + The HTTP client used to interact with the Discord API. """ def __init__( @@ -37,15 +46,6 @@ def __init__( The intents of the bot. version: int The version of the Discord API to use. Defaults to 10. - - Attributes - ---------- - token: str - The token of the bot. - intents: Intents - The intents of the bot. - http: HTTPClient - The HTTP client used to interact with the Discord API. """ super().__init__( token, diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 57b8edfa..e6588580 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -75,14 +75,6 @@ def __init__( Should be kept private at all times. version: int The version of the Discord API to use. - session: aiohttp.ClientSession - The ClientSession used to make requests. - buckets: Dict[str, Union[Bucket, TopLevelBucket]] - The buckets used to ratelimit requests. - global_event: asyncio.Event - The event used to wait for the global ratelimit to end. - error_mapping: Dict[int, Type[HTTPException]] - The mapping of status codes to exceptions. """ self.token: TokenStore = token self.version: APIVersion = version diff --git a/EpikCord/client/rate_limit_tools.py b/EpikCord/client/rate_limit_tools.py index 275c88cf..e80fe90e 100644 --- a/EpikCord/client/rate_limit_tools.py +++ b/EpikCord/client/rate_limit_tools.py @@ -56,19 +56,20 @@ async def handle_exhaustion(self, retry_after: int): class Bucket: + """ + Attributes + ---------- + hash: str + The bucket hash of the bucket provided by Discord. + event: asyncio.Event + The event that is used to wait for the bucket to be set. + """ def __init__(self, *, bucket_hash: str): """ Parameters ---------- bucket_hash: str The bucket hash of the bucket provided by Discord. - - Attributes - ---------- - bucket_hash: str - The bucket hash of the bucket provided by Discord. - event: asyncio.Event - The event that is used to wait for the bucket to be set. """ self.hash: str = bucket_hash self.event: asyncio.Event = asyncio.Event() @@ -119,6 +120,23 @@ async def handle_exhaustion(self, retry_after: int): class TopLevelBucket: + """ + Attributes + ---------- + channel_id: Optional[int] + The channel ID of the bucket. + guild_id: Optional[int] + The guild ID of the bucket. + webhook_id: Optional[int] = None + The webhook ID of the bucket. + webhook_token: Optional[str] + The webhook token of the bucket. + major_parameters: Dict[str, Any] + The major parameters for this Bucket + event: asyncio.Event + The event that is used to wait for the bucket to be set. + """ + def __init__( self, *, @@ -138,21 +156,6 @@ def __init__( The webhook ID of the bucket. webhook_token: Optional[str] The webhook token of the bucket. - - Attributes - ---------- - channel_id: Optional[int] - The channel ID of the bucket. - guild_id: Optional[int] - The guild ID of the bucket. - webhook_id: Optional[int] = None - The webhook ID of the bucket. - webhook_token: Optional[str] - The webhook token of the bucket. - major_parameters: Dict[str, Any] - The major parameters for this Bucket - event: asyncio.Event - The event that is used to wait for the bucket to be set. """ self.event = asyncio.Event() self.event.set() @@ -222,6 +225,22 @@ async def handle_exhaustion(self, retry_after: int): class MajorParameters: + """A class to group up all the major parameters of a route. + + Attributes + ---------- + channel_id: Optional[int] + The channel id in the route. + guild_id: Optional[int] + The guild id in the route. + webhook_id: Optional[int] + The webhook id in the route. + webhook_token: Optional[str] + The webhook token in the route. + major_parameters: Dict[str, Union[int, str]] + The major parameters compiled into a single dictionary. + """ + def __init__( self, channel_id: Optional[int] = None, @@ -241,19 +260,6 @@ def __init__( The webhook id in the route. webhook_token: Optional[str] The webhook token in the route. - - Attributes - ---------- - channel_id: Optional[int] - The channel id in the route. - guild_id: Optional[int] - The guild id in the route. - webhook_id: Optional[int] - The webhook id in the route. - webhook_token: Optional[str] - The webhook token in the route. - major_parameters: Dict[str, Union[int, str]] - The major parameters compiled into a single dictionary. """ self.channel_id: Optional[int] = channel_id self.guild_id: Optional[int] = guild_id diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index c7c7f463..a3849a2b 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -143,7 +143,7 @@ async def heartbeat(self, *, forced: Optional[bool] = False): Parameters ---------- forced : Optional[bool] - Whether or not to send the heartbeat now. + Whether to send the heartbeat now. """ if not self.client.heartbeat_interval: diff --git a/EpikCord/file.py b/EpikCord/file.py index 4cc00780..d6190623 100644 --- a/EpikCord/file.py +++ b/EpikCord/file.py @@ -23,9 +23,7 @@ def __init__( mime_type: Optional[str] The mime type of the file. If not provided, it will be guessed. spoiler: bool - Whether or not the file is a spoiler. - ephemeral: bool - Whether or not the file is ephemeral. + Whether the file is a spoiler. description: Optional[str] The description of the file. """ From e20c005e4c25c534444da29d10c3eb91697be5ca Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Mon, 30 Jan 2023 22:51:57 +0100 Subject: [PATCH 138/335] :sparkles: Add status codes --- EpikCord/status_code.py | 276 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 EpikCord/status_code.py diff --git a/EpikCord/status_code.py b/EpikCord/status_code.py new file mode 100644 index 00000000..92321547 --- /dev/null +++ b/EpikCord/status_code.py @@ -0,0 +1,276 @@ +from __future__ import annotations + +from enum import IntEnum + + +class StatusCode(IntEnum): + pass + + +class HTTPCodes(StatusCode): + """HTTP response codes.""" + + OK = 200 + CREATED = 201 + + NO_CONTENT = 204 + NOT_MODIFIED = 304 + + BAD_REQUEST = 400 + UNAUTHORIZED = 401 + FORBIDDEN = 403 + NOT_FOUND = 404 + METHOD_NOT_ALLOWED = 405 + + TOO_MANY_REQUESTS = 429 + + GATEWAY_UNAVAILABLE = 502 + SERVER_ERROR = 500 + + @classmethod + def _missing_(cls, value: object) -> HTTPCodes: + if not isinstance(value, int): + raise ValueError(f"{value} is not a valid HTTP status code.") + + if value == HTTPCodes.GATEWAY_UNAVAILABLE: + return HTTPCodes.GATEWAY_UNAVAILABLE + + if value > HTTPCodes.SERVER_ERROR: + return HTTPCodes.SERVER_ERROR + + raise ValueError( + f"HTTP Status Code `{value}` is undocumented.\n" + "Please create an issue at:" + " https://github.com/EpikCord/EpikCord.py/issues" + " with the following traceback." + ) + + +class JsonErrorCodes(StatusCode): + GENERAL_ERROR = 0 + + UNKNOWN_ACCOUNT = 10001 + UNKNOWN_APPLICATION = 10002 + UNKNOWN_CHANNEL = 10003 + UNKNOWN_GUILD = 10004 + UNKNOWN_INTEGRATION = 10005 + UNKNOWN_INVITE = 10006 + UNKNOWN_MEMBER = 10007 + UNKNOWN_MESSAGE = 10008 + UNKNOWN_OVERWRITE = 10009 + UNKNOWN_PROVIDER = 10010 + UNKNOWN_ROLE = 10011 + UNKNOWN_TOKEN = 10012 + UNKNOWN_USER = 10013 + UNKNOWN_EMOJI = 10014 + UNKNOWN_WEBHOOK = 10015 + UNKNOWN_WEBHOOK_SERVICE = 10016 + + UNKNOWN_SESSION = 10020 + + UNKNOWN_BAN = 10026 + UNKNOWN_SKU = 10027 + + UNKNOWN_STORE_LISTING = 10028 + UNKNOWN_ENTITLEMENT = 10029 + + UNKNOWN_BUILD = 10030 + UNKNOWN_LOBBY = 10031 + + UNKNOWN_BRANCH = 10032 + UNKNOWN_STORE_DIRECTORY_LAYOUT = 10033 + + UNKNOWN_REDISTRIBUTABLE = 10036 + + UNKNOWN_GIFT_CODE = 10038 + + UNKNOWN_STREAM = 10049 + UNKNOWN_PREMIUM_SERVER_SUB_COOLDOWN = 10050 + + UNKNOWN_GUILD_TEMPLATE = 10057 + + UNKNOWN_DISCOVERABLE_SERVER_CATEGORY = 10059 + UNKNOWN_STICKER = 10060 + + UNKNOWN_INTERACTION = 10062 + UNKNOWN_APPLICATION_COMMAND = 10063 + + UNKNOWN_VOICE_STATE = 10065 + UNKNOWN_APP_COMMAND_PERMS = 10066 + UNKNOWN_STAGE_INSTANCE = 10067 + UNKNOWN_GUILD_MEMBER_VERIFICATION_FORM = 10068 + UNKNOWN_GUILD_WELCOME_SCREEN = 10069 + UNKNOWN_GUILD_SCHEDULED_EVENT = 10070 + UNKNOWN_GUILD_SCHEDULED_EVENT_USER = 10071 + + UNKNOWN_TAG = 10087 + + NOT_BOT_ENDPOINT = 20001 + ONLY_BOT_ENDPOINT = 20002 + + EXPLICIT_CONTENT_CANNOT_BE_SEND = 20009 + + APP_UNAUTHORIZED = 20012 + + SLOWMODE_LIMIT_FAILURE = 20016 + + ACCOUNT_OWNERSHIP_REQUIRED = 20018 + + ANNOUNCEMENT_RATE_LIMIT = 20022 + + CHANNEL_WRITE_RATE_LIMIT = 20028 + SERVER_ACTION_RATE_LIMIT = 20029 + + METHOD_NOT_ALLOWED = 20031 + + INSUFFICIENT_GUILD_PREMIUM_SUBSCRIPTION = 20035 + + MAX_GUILD_REACHED = 30001 + MAX_FRIENDS_REACHED = 30002 + MAX_PINS_REACHED = 30003 + MAX_RECIPIENT_REACHED = 30004 + MAX_GUILD_ROLE_REACHED = 30005 + + MAX_WEBHOOKS_REACHED = 30007 + MAX_EMOJIS_REACHED = 30008 + + MAX_REACTIONS_REACHED = 30010 + + MAX_GUILD_CHANNELS_REACHED = 30013 + + MAX_ATTACHMENTS_REACHED = 30015 + MAX_INVITE_CODES_REACHED = 30016 + + MAX_ANIMATED_EMOTES_REACHED = 30018 + MAX_SERVER_MEMBER_REACHED = 30019 + + MAX_SERVER_CATEGORY_REACHED = 30030 + GUILD_ALREADY_HAS_TEMPLATE = 30031 + + MAX_THREAD_PARTICIPANTS_REACHED = 30033 + + MAX_NON_GUILD_BANS_REACHED = 30035 + + MAX_BAN_FETCH_REACHED = 30037 + MAX_INCOMPLETE_GUILD_SCHEDULED_EVENTS_REACHED = 30038 + MAX_STICKER_REACHED = 30039 + MAX_MEMBER_PRUNE_REACHED = 30040 + + MAX_GUILD_WIDGET_UPDATE_REACHED = 30042 + + MAX_SUP_HOUR_EDIT = 30046 + MAX_PIN_THREAD_REACHED = 30047 + MAX_TAG_REACHED = 30048 + + UNAUTHORIZED_TOKEN = 40001 + ACCOUNT_VERIFICATION_NEEDED = 40002 + DIRECT_MESSAGE_RATE_LIMITED = 40003 + SEND_MESSAGE_DISABLED = 40004 + REQUEST_TOO_LARGE = 40005 + DISABLED_FEATURE = 40006 + BANNED_FROM_GUILD = 40007 + + NOT_CONNECTED_TO_VOICE = 40032 + ALREADY_CROSSPOSTED = 40033 + + APP_COMMAND_ALREADY_EXIST = 40041 + + INTERACTION_ALREADY_ACKNOWLEDGED = 40060 + + MISSING_ACCESS = 50001 + INVALID_ACCOUNT_TYPE = 50002 + DM_CHANNEL_ACTION_PROHIBITED = 50003 + GUILD_WIDGET_DISABLED = 50004 + MESSAGE_AUTHORED_BY_ANOTHER_USER = 50005 + EMPTY_MESSAGE = 50006 + USER_NOT_MESSAGABLE = 50007 + NON_TEXT_CHANNEL = 50008 + CHANNEL_VERIFICATION_TOO_HIGH = 50009 + OAUTH2_APPLICATION_BOT_AUTH_INVALID = 50010 + OAUTH2_APP_LIMIT_REACHED = 50011 + INVALID_OAUTH2_STATE = 50012 + ACTION_PERMISSION_MISSING = 50013 + INVALID_AUTH_TOKEN = 50014 + NOTE_TOO_LONG = 50015 + DELETE_OUT_OF_RANGE = 50016 + + INVALID_PIN_CHANNEL = 50019 + INVALID_INVITE_CODE = 50020 + ACTION_ON_SYSTEM_MESSAGE_PROHIBITED = 50021 + + ACTION_ON_WRONG_CHANNEL_TYPE = 50024 + OAUTH2_TOKEN_INVALID = 50025 + MISSING_OAUTH2_SCOPE = 50026 + INVALID_WEBHOOK_TOKEN = 50027 + INVALID_ROLE = 50028 + + INVALID_RECIPIENT = 50033 + MESSAGE_TOO_OLD = 50034 + INVALID_FORM_BODY = 50035 + INVITE_ACCEPTED_TO_GUILD_NOT_MEMBER = 50036 + + INVALID_API_VERSION = 50041 + + FILE_UPLOAD_SIZE_LIMIT_EXCEEDED = 50045 + INVALID_FILE_UPLOAD = 50046 + + CANNOT_SELF_REDEEM_GIFT = 50054 + INVALID_GUILD = 50055 + + INVALID_MESSAGE_TYPE = 50068 + + PAYMENT_SOURCE_REQUIRED = 50070 + + CHANNEL_REQUIRED_FOR_COMMUNITY_GUILD = 50074 + + CANNOT_EDIT_MESSAGE_STICKER = 50080 + INVALID_STICKER_SEND = 50081 + + INVALID_ACTION_ON_THREAD_ARCHIVED = 50083 + INVALID_THREAD_NOTIFICATION_SETTINGS = 50084 + BEFORE_DATE_EARLIER_THAN_THEAD_CREATION = 50085 + COMMUNITY_CHANNEL_MUST_BE_A_TEXT_CHANNEL = 50086 + + SERVER_NOT_AVAILABLE_FOR_LOCATION = 50095 + + SERVER_REQUIRED_MONETIZATION = 50097 + + SERVER_BOOTS_INSUFFICIENT = 50101 + + INVALID_JSON_BODY = 50109 + + STICKER_PERMISSION_MISSING = 50600 + TWO_FACTOR_AUTH_REQUIRED = 50603 + + DISCORD_TAG_UNALLOCATED = 80004 + + REACTION_BLOCKED = 90001 + + API_RESOURCE_OVERLOADED = 130000 + STAGE_ALREADY_OPEN = 150006 + + HISTORY_PERMISSION_MISSING_TO_REPLY = 160002 + + THREAD_ALREADY_CREATED_FOR_MESSAGE = 160004 + LOCKED_THREAD = 160005 + MAX_ACTIVE_THREADS_REACHED = 160006 + MAX_MEMBER_ACTIVE_ANNOUNCEMENTS_THREAD_REACHED = 160007 + + INVALID_JSON_LOTTIE_FILE = 170001 + RASTERIZED_IMG_IN_LOTTIE_FILE = 170002 + MAX_STICKER_FRAMERATE_REACHED = 170003 + STICKER_FRAME_COUNT_EXCEEDED = 170004 + LOTTIE_ANIMATION_DIMENSIONS_EXCEEDED = 170005 + STICKER_FRAME_RATE_OUT_OF_RANGE = 170006 + STICKER_ANIMATION_DURATION_EXCEEDED = 170007 + + CANNOT_UPLOAD_FINISHED_EVENT = 180000 + + STAGE_CREATION_ON_STAGE_EVENT_FAILED = 180002 + + @classmethod + def _missing_(cls, value: object) -> JsonErrorCodes: + return cls.GENERAL_ERROR + + +__all__ = ("HTTPCodes", "JsonErrorCodes") From 88894780692b6aad9b571c7bc2bd684ca0839806 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Mon, 30 Jan 2023 22:52:12 +0100 Subject: [PATCH 139/335] :recycle: Small code improvements --- EpikCord/client/http.py | 4 ++-- EpikCord/client/websocket.py | 11 +++++------ EpikCord/presence.py | 20 ++++++++++---------- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index e6588580..7af26aad 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -17,6 +17,7 @@ Unauthorized, ) from ..file import File +from ..status_code import HTTPCodes from ..utils import ( add_file, clean_url, @@ -157,10 +158,9 @@ async def request( ) data = await extract_content(response) - await log_request(response, data) - if response.status == 429: + if response.status == HTTPCodes.TOO_MANY_REQUESTS: await self.handle_ratelimit(data, bucket) elif response.headers.get("X-RateLimit-Remaining", "1") == "0": await bucket.handle_exhaustion( diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index a3849a2b..454ae6a6 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -67,7 +67,8 @@ def __init__(self, client: WebSocketClient): OpCode.HEARTBEAT_ACK: self.heartbeat_ack, } - async def heartbeat_ack(self, data: Any): + @staticmethod + async def heartbeat_ack(_: Any): """Handle the heartbeat ack event. OpCode 11.""" logger.debug("Received heartbeat ack from Gateway") @@ -197,9 +198,9 @@ async def resume(self): class DiscordWSMessage: - def __init__(self, *, data, type, extra): + def __init__(self, *, data, msg_type, extra): self.data = data - self.type = type + self.type = msg_type self.extra = extra def json(self) -> Any: @@ -217,19 +218,17 @@ async def receive(self, *args, **kwargs): message = ws_message.data if isinstance(message, bytes): - self.buffer.extend(message) if len(message) < 4 or message[-4:] != b"\x00\x00\xff\xff": return message = self.inflator.decompress(self.buffer) - message = message.decode("utf-8") self.buffer = bytearray() return DiscordWSMessage( - data=message, type=ws_message.type, extra=ws_message.extra + data=message, msg_type=ws_message.type, extra=ws_message.extra ) diff --git a/EpikCord/presence.py b/EpikCord/presence.py index 7656eb1c..9877c862 100644 --- a/EpikCord/presence.py +++ b/EpikCord/presence.py @@ -29,9 +29,8 @@ class ActivityType(IntEnum): COMPETING = 5 -class UpdatePresenceData( - TypedDict -): # Data sent to Discord when updating presence +class UpdatePresenceData(TypedDict): + # Data sent to Discord when updating presence status: NotRequired[str] activities: NotRequired[List[ActivityPayload]] afk: bool @@ -40,7 +39,7 @@ class UpdatePresenceData( class Activity: def __init__( - self, *, name: str, type: ActivityType, url: Optional[str] = None + self, *, name: str, ac_type: ActivityType, url: Optional[str] = None ): """Represents a Discord activity. @@ -48,25 +47,26 @@ def __init__( ---------- name: :class:`str` The name of the activity. - type: :class:`ActivityType` + ac_type: :class:`ActivityType` The type of the activity. url: Optional[:class:`str`] The url of the activity. Only used for streaming (activity type 1). """ self.name = name - self.type = type + self.type = ac_type self.url = url def to_dict(self) -> ActivityPayload: """Converts the activity to a dictionary.""" data: ActivityPayload = {"name": self.name, "type": self.type.value} - if self.url is not None and not self.type == ActivityType.STREAMING: - raise ValueError("URL can only be set for streaming activities.") + if self.url is None: + return data - if self.url is not None: - data["url"] = self.url + if self.type != ActivityType.STREAMING: + raise ValueError("URL can only be set for streaming activities.") + data["url"] = self.url return data From da39a64a82efa506143924593dfa1904c4ec8ce1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 30 Jan 2023 21:52:27 +0000 Subject: [PATCH 140/335] :art: Format code automatically --- EpikCord/client/websocket.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 0ac2489a..4f4f2b33 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -211,7 +211,9 @@ async def handle(self, message: DiscordWSMessage): else: logger.error("Unhandled opcode %s", event["op"]) else: - await self.dispatch(event["t"].lower(), event["d"]) # TODO: Once we have completed the HTTP objects, we can then start to transform them before they reach the end user. + await self.dispatch( + event["t"].lower(), event["d"] + ) # TODO: Once we have completed the HTTP objects, we can then start to transform them before they reach the end user. async def resume(self): ... From 6d4ab83a53ef541988df483b6a37b7217c78f3e3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 30 Jan 2023 21:52:48 +0000 Subject: [PATCH 141/335] :art: Format code automatically --- EpikCord/client/rate_limit_tools.py | 1 + 1 file changed, 1 insertion(+) diff --git a/EpikCord/client/rate_limit_tools.py b/EpikCord/client/rate_limit_tools.py index e80fe90e..f2ca26e9 100644 --- a/EpikCord/client/rate_limit_tools.py +++ b/EpikCord/client/rate_limit_tools.py @@ -64,6 +64,7 @@ class Bucket: event: asyncio.Event The event that is used to wait for the bucket to be set. """ + def __init__(self, *, bucket_hash: str): """ Parameters From 226ce7711051aa0a7faf601ce9e581b79066d845 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Mon, 30 Jan 2023 23:05:11 +0100 Subject: [PATCH 142/335] :truck: Move status codes enums to enums.py --- EpikCord/status_code.py | 276 ---------------------------------------- EpikCord/utils/enums.py | 272 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 272 insertions(+), 276 deletions(-) diff --git a/EpikCord/status_code.py b/EpikCord/status_code.py index 92321547..e69de29b 100644 --- a/EpikCord/status_code.py +++ b/EpikCord/status_code.py @@ -1,276 +0,0 @@ -from __future__ import annotations - -from enum import IntEnum - - -class StatusCode(IntEnum): - pass - - -class HTTPCodes(StatusCode): - """HTTP response codes.""" - - OK = 200 - CREATED = 201 - - NO_CONTENT = 204 - NOT_MODIFIED = 304 - - BAD_REQUEST = 400 - UNAUTHORIZED = 401 - FORBIDDEN = 403 - NOT_FOUND = 404 - METHOD_NOT_ALLOWED = 405 - - TOO_MANY_REQUESTS = 429 - - GATEWAY_UNAVAILABLE = 502 - SERVER_ERROR = 500 - - @classmethod - def _missing_(cls, value: object) -> HTTPCodes: - if not isinstance(value, int): - raise ValueError(f"{value} is not a valid HTTP status code.") - - if value == HTTPCodes.GATEWAY_UNAVAILABLE: - return HTTPCodes.GATEWAY_UNAVAILABLE - - if value > HTTPCodes.SERVER_ERROR: - return HTTPCodes.SERVER_ERROR - - raise ValueError( - f"HTTP Status Code `{value}` is undocumented.\n" - "Please create an issue at:" - " https://github.com/EpikCord/EpikCord.py/issues" - " with the following traceback." - ) - - -class JsonErrorCodes(StatusCode): - GENERAL_ERROR = 0 - - UNKNOWN_ACCOUNT = 10001 - UNKNOWN_APPLICATION = 10002 - UNKNOWN_CHANNEL = 10003 - UNKNOWN_GUILD = 10004 - UNKNOWN_INTEGRATION = 10005 - UNKNOWN_INVITE = 10006 - UNKNOWN_MEMBER = 10007 - UNKNOWN_MESSAGE = 10008 - UNKNOWN_OVERWRITE = 10009 - UNKNOWN_PROVIDER = 10010 - UNKNOWN_ROLE = 10011 - UNKNOWN_TOKEN = 10012 - UNKNOWN_USER = 10013 - UNKNOWN_EMOJI = 10014 - UNKNOWN_WEBHOOK = 10015 - UNKNOWN_WEBHOOK_SERVICE = 10016 - - UNKNOWN_SESSION = 10020 - - UNKNOWN_BAN = 10026 - UNKNOWN_SKU = 10027 - - UNKNOWN_STORE_LISTING = 10028 - UNKNOWN_ENTITLEMENT = 10029 - - UNKNOWN_BUILD = 10030 - UNKNOWN_LOBBY = 10031 - - UNKNOWN_BRANCH = 10032 - UNKNOWN_STORE_DIRECTORY_LAYOUT = 10033 - - UNKNOWN_REDISTRIBUTABLE = 10036 - - UNKNOWN_GIFT_CODE = 10038 - - UNKNOWN_STREAM = 10049 - UNKNOWN_PREMIUM_SERVER_SUB_COOLDOWN = 10050 - - UNKNOWN_GUILD_TEMPLATE = 10057 - - UNKNOWN_DISCOVERABLE_SERVER_CATEGORY = 10059 - UNKNOWN_STICKER = 10060 - - UNKNOWN_INTERACTION = 10062 - UNKNOWN_APPLICATION_COMMAND = 10063 - - UNKNOWN_VOICE_STATE = 10065 - UNKNOWN_APP_COMMAND_PERMS = 10066 - UNKNOWN_STAGE_INSTANCE = 10067 - UNKNOWN_GUILD_MEMBER_VERIFICATION_FORM = 10068 - UNKNOWN_GUILD_WELCOME_SCREEN = 10069 - UNKNOWN_GUILD_SCHEDULED_EVENT = 10070 - UNKNOWN_GUILD_SCHEDULED_EVENT_USER = 10071 - - UNKNOWN_TAG = 10087 - - NOT_BOT_ENDPOINT = 20001 - ONLY_BOT_ENDPOINT = 20002 - - EXPLICIT_CONTENT_CANNOT_BE_SEND = 20009 - - APP_UNAUTHORIZED = 20012 - - SLOWMODE_LIMIT_FAILURE = 20016 - - ACCOUNT_OWNERSHIP_REQUIRED = 20018 - - ANNOUNCEMENT_RATE_LIMIT = 20022 - - CHANNEL_WRITE_RATE_LIMIT = 20028 - SERVER_ACTION_RATE_LIMIT = 20029 - - METHOD_NOT_ALLOWED = 20031 - - INSUFFICIENT_GUILD_PREMIUM_SUBSCRIPTION = 20035 - - MAX_GUILD_REACHED = 30001 - MAX_FRIENDS_REACHED = 30002 - MAX_PINS_REACHED = 30003 - MAX_RECIPIENT_REACHED = 30004 - MAX_GUILD_ROLE_REACHED = 30005 - - MAX_WEBHOOKS_REACHED = 30007 - MAX_EMOJIS_REACHED = 30008 - - MAX_REACTIONS_REACHED = 30010 - - MAX_GUILD_CHANNELS_REACHED = 30013 - - MAX_ATTACHMENTS_REACHED = 30015 - MAX_INVITE_CODES_REACHED = 30016 - - MAX_ANIMATED_EMOTES_REACHED = 30018 - MAX_SERVER_MEMBER_REACHED = 30019 - - MAX_SERVER_CATEGORY_REACHED = 30030 - GUILD_ALREADY_HAS_TEMPLATE = 30031 - - MAX_THREAD_PARTICIPANTS_REACHED = 30033 - - MAX_NON_GUILD_BANS_REACHED = 30035 - - MAX_BAN_FETCH_REACHED = 30037 - MAX_INCOMPLETE_GUILD_SCHEDULED_EVENTS_REACHED = 30038 - MAX_STICKER_REACHED = 30039 - MAX_MEMBER_PRUNE_REACHED = 30040 - - MAX_GUILD_WIDGET_UPDATE_REACHED = 30042 - - MAX_SUP_HOUR_EDIT = 30046 - MAX_PIN_THREAD_REACHED = 30047 - MAX_TAG_REACHED = 30048 - - UNAUTHORIZED_TOKEN = 40001 - ACCOUNT_VERIFICATION_NEEDED = 40002 - DIRECT_MESSAGE_RATE_LIMITED = 40003 - SEND_MESSAGE_DISABLED = 40004 - REQUEST_TOO_LARGE = 40005 - DISABLED_FEATURE = 40006 - BANNED_FROM_GUILD = 40007 - - NOT_CONNECTED_TO_VOICE = 40032 - ALREADY_CROSSPOSTED = 40033 - - APP_COMMAND_ALREADY_EXIST = 40041 - - INTERACTION_ALREADY_ACKNOWLEDGED = 40060 - - MISSING_ACCESS = 50001 - INVALID_ACCOUNT_TYPE = 50002 - DM_CHANNEL_ACTION_PROHIBITED = 50003 - GUILD_WIDGET_DISABLED = 50004 - MESSAGE_AUTHORED_BY_ANOTHER_USER = 50005 - EMPTY_MESSAGE = 50006 - USER_NOT_MESSAGABLE = 50007 - NON_TEXT_CHANNEL = 50008 - CHANNEL_VERIFICATION_TOO_HIGH = 50009 - OAUTH2_APPLICATION_BOT_AUTH_INVALID = 50010 - OAUTH2_APP_LIMIT_REACHED = 50011 - INVALID_OAUTH2_STATE = 50012 - ACTION_PERMISSION_MISSING = 50013 - INVALID_AUTH_TOKEN = 50014 - NOTE_TOO_LONG = 50015 - DELETE_OUT_OF_RANGE = 50016 - - INVALID_PIN_CHANNEL = 50019 - INVALID_INVITE_CODE = 50020 - ACTION_ON_SYSTEM_MESSAGE_PROHIBITED = 50021 - - ACTION_ON_WRONG_CHANNEL_TYPE = 50024 - OAUTH2_TOKEN_INVALID = 50025 - MISSING_OAUTH2_SCOPE = 50026 - INVALID_WEBHOOK_TOKEN = 50027 - INVALID_ROLE = 50028 - - INVALID_RECIPIENT = 50033 - MESSAGE_TOO_OLD = 50034 - INVALID_FORM_BODY = 50035 - INVITE_ACCEPTED_TO_GUILD_NOT_MEMBER = 50036 - - INVALID_API_VERSION = 50041 - - FILE_UPLOAD_SIZE_LIMIT_EXCEEDED = 50045 - INVALID_FILE_UPLOAD = 50046 - - CANNOT_SELF_REDEEM_GIFT = 50054 - INVALID_GUILD = 50055 - - INVALID_MESSAGE_TYPE = 50068 - - PAYMENT_SOURCE_REQUIRED = 50070 - - CHANNEL_REQUIRED_FOR_COMMUNITY_GUILD = 50074 - - CANNOT_EDIT_MESSAGE_STICKER = 50080 - INVALID_STICKER_SEND = 50081 - - INVALID_ACTION_ON_THREAD_ARCHIVED = 50083 - INVALID_THREAD_NOTIFICATION_SETTINGS = 50084 - BEFORE_DATE_EARLIER_THAN_THEAD_CREATION = 50085 - COMMUNITY_CHANNEL_MUST_BE_A_TEXT_CHANNEL = 50086 - - SERVER_NOT_AVAILABLE_FOR_LOCATION = 50095 - - SERVER_REQUIRED_MONETIZATION = 50097 - - SERVER_BOOTS_INSUFFICIENT = 50101 - - INVALID_JSON_BODY = 50109 - - STICKER_PERMISSION_MISSING = 50600 - TWO_FACTOR_AUTH_REQUIRED = 50603 - - DISCORD_TAG_UNALLOCATED = 80004 - - REACTION_BLOCKED = 90001 - - API_RESOURCE_OVERLOADED = 130000 - STAGE_ALREADY_OPEN = 150006 - - HISTORY_PERMISSION_MISSING_TO_REPLY = 160002 - - THREAD_ALREADY_CREATED_FOR_MESSAGE = 160004 - LOCKED_THREAD = 160005 - MAX_ACTIVE_THREADS_REACHED = 160006 - MAX_MEMBER_ACTIVE_ANNOUNCEMENTS_THREAD_REACHED = 160007 - - INVALID_JSON_LOTTIE_FILE = 170001 - RASTERIZED_IMG_IN_LOTTIE_FILE = 170002 - MAX_STICKER_FRAMERATE_REACHED = 170003 - STICKER_FRAME_COUNT_EXCEEDED = 170004 - LOTTIE_ANIMATION_DIMENSIONS_EXCEEDED = 170005 - STICKER_FRAME_RATE_OUT_OF_RANGE = 170006 - STICKER_ANIMATION_DURATION_EXCEEDED = 170007 - - CANNOT_UPLOAD_FINISHED_EVENT = 180000 - - STAGE_CREATION_ON_STAGE_EVENT_FAILED = 180002 - - @classmethod - def _missing_(cls, value: object) -> JsonErrorCodes: - return cls.GENERAL_ERROR - - -__all__ = ("HTTPCodes", "JsonErrorCodes") diff --git a/EpikCord/utils/enums.py b/EpikCord/utils/enums.py index aef5a70a..d53db781 100644 --- a/EpikCord/utils/enums.py +++ b/EpikCord/utils/enums.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from enum import IntEnum @@ -69,3 +71,273 @@ class VoiceCloseCode(IntEnum): DISCONNECTED = 4014 VOICE_SERVER_CRASHED = 4015 UNKNOWN_ENCRYPTION_MODE = 4016 + + +class StatusCode(IntEnum): + pass + + +class HTTPCodes(StatusCode): + """HTTP response codes.""" + + OK = 200 + CREATED = 201 + + NO_CONTENT = 204 + NOT_MODIFIED = 304 + + BAD_REQUEST = 400 + UNAUTHORIZED = 401 + FORBIDDEN = 403 + NOT_FOUND = 404 + METHOD_NOT_ALLOWED = 405 + + TOO_MANY_REQUESTS = 429 + + GATEWAY_UNAVAILABLE = 502 + SERVER_ERROR = 500 + + @classmethod + def _missing_(cls, value: object) -> HTTPCodes: + if not isinstance(value, int): + raise ValueError(f"{value} is not a valid HTTP status code.") + + if value == HTTPCodes.GATEWAY_UNAVAILABLE: + return HTTPCodes.GATEWAY_UNAVAILABLE + + if value > HTTPCodes.SERVER_ERROR: + return HTTPCodes.SERVER_ERROR + + raise ValueError( + f"HTTP Status Code `{value}` is undocumented.\n" + "Please create an issue at:" + " https://github.com/EpikCord/EpikCord.py/issues" + " with the following traceback." + ) + + +class JSONErrorCodes(StatusCode): + GENERAL_ERROR = 0 + + UNKNOWN_ACCOUNT = 10001 + UNKNOWN_APPLICATION = 10002 + UNKNOWN_CHANNEL = 10003 + UNKNOWN_GUILD = 10004 + UNKNOWN_INTEGRATION = 10005 + UNKNOWN_INVITE = 10006 + UNKNOWN_MEMBER = 10007 + UNKNOWN_MESSAGE = 10008 + UNKNOWN_OVERWRITE = 10009 + UNKNOWN_PROVIDER = 10010 + UNKNOWN_ROLE = 10011 + UNKNOWN_TOKEN = 10012 + UNKNOWN_USER = 10013 + UNKNOWN_EMOJI = 10014 + UNKNOWN_WEBHOOK = 10015 + UNKNOWN_WEBHOOK_SERVICE = 10016 + + UNKNOWN_SESSION = 10020 + + UNKNOWN_BAN = 10026 + UNKNOWN_SKU = 10027 + + UNKNOWN_STORE_LISTING = 10028 + UNKNOWN_ENTITLEMENT = 10029 + + UNKNOWN_BUILD = 10030 + UNKNOWN_LOBBY = 10031 + + UNKNOWN_BRANCH = 10032 + UNKNOWN_STORE_DIRECTORY_LAYOUT = 10033 + + UNKNOWN_REDISTRIBUTABLE = 10036 + + UNKNOWN_GIFT_CODE = 10038 + + UNKNOWN_STREAM = 10049 + UNKNOWN_PREMIUM_SERVER_SUB_COOLDOWN = 10050 + + UNKNOWN_GUILD_TEMPLATE = 10057 + + UNKNOWN_DISCOVERABLE_SERVER_CATEGORY = 10059 + UNKNOWN_STICKER = 10060 + + UNKNOWN_INTERACTION = 10062 + UNKNOWN_APPLICATION_COMMAND = 10063 + + UNKNOWN_VOICE_STATE = 10065 + UNKNOWN_APP_COMMAND_PERMS = 10066 + UNKNOWN_STAGE_INSTANCE = 10067 + UNKNOWN_GUILD_MEMBER_VERIFICATION_FORM = 10068 + UNKNOWN_GUILD_WELCOME_SCREEN = 10069 + UNKNOWN_GUILD_SCHEDULED_EVENT = 10070 + UNKNOWN_GUILD_SCHEDULED_EVENT_USER = 10071 + + UNKNOWN_TAG = 10087 + + NOT_BOT_ENDPOINT = 20001 + ONLY_BOT_ENDPOINT = 20002 + + EXPLICIT_CONTENT_CANNOT_BE_SEND = 20009 + + APP_UNAUTHORIZED = 20012 + + SLOWMODE_LIMIT_FAILURE = 20016 + + ACCOUNT_OWNERSHIP_REQUIRED = 20018 + + ANNOUNCEMENT_RATE_LIMIT = 20022 + + CHANNEL_WRITE_RATE_LIMIT = 20028 + SERVER_ACTION_RATE_LIMIT = 20029 + + METHOD_NOT_ALLOWED = 20031 + + INSUFFICIENT_GUILD_PREMIUM_SUBSCRIPTION = 20035 + + MAX_GUILD_REACHED = 30001 + MAX_FRIENDS_REACHED = 30002 + MAX_PINS_REACHED = 30003 + MAX_RECIPIENT_REACHED = 30004 + MAX_GUILD_ROLE_REACHED = 30005 + + MAX_WEBHOOKS_REACHED = 30007 + MAX_EMOJIS_REACHED = 30008 + + MAX_REACTIONS_REACHED = 30010 + + MAX_GUILD_CHANNELS_REACHED = 30013 + + MAX_ATTACHMENTS_REACHED = 30015 + MAX_INVITE_CODES_REACHED = 30016 + + MAX_ANIMATED_EMOTES_REACHED = 30018 + MAX_SERVER_MEMBER_REACHED = 30019 + + MAX_SERVER_CATEGORY_REACHED = 30030 + GUILD_ALREADY_HAS_TEMPLATE = 30031 + + MAX_THREAD_PARTICIPANTS_REACHED = 30033 + + MAX_NON_GUILD_BANS_REACHED = 30035 + + MAX_BAN_FETCH_REACHED = 30037 + MAX_INCOMPLETE_GUILD_SCHEDULED_EVENTS_REACHED = 30038 + MAX_STICKER_REACHED = 30039 + MAX_MEMBER_PRUNE_REACHED = 30040 + + MAX_GUILD_WIDGET_UPDATE_REACHED = 30042 + + MAX_SUP_HOUR_EDIT = 30046 + MAX_PIN_THREAD_REACHED = 30047 + MAX_TAG_REACHED = 30048 + + UNAUTHORIZED_TOKEN = 40001 + ACCOUNT_VERIFICATION_NEEDED = 40002 + DIRECT_MESSAGE_RATE_LIMITED = 40003 + SEND_MESSAGE_DISABLED = 40004 + REQUEST_TOO_LARGE = 40005 + DISABLED_FEATURE = 40006 + BANNED_FROM_GUILD = 40007 + + NOT_CONNECTED_TO_VOICE = 40032 + ALREADY_CROSSPOSTED = 40033 + + APP_COMMAND_ALREADY_EXIST = 40041 + + INTERACTION_ALREADY_ACKNOWLEDGED = 40060 + + MISSING_ACCESS = 50001 + INVALID_ACCOUNT_TYPE = 50002 + DM_CHANNEL_ACTION_PROHIBITED = 50003 + GUILD_WIDGET_DISABLED = 50004 + MESSAGE_AUTHORED_BY_ANOTHER_USER = 50005 + EMPTY_MESSAGE = 50006 + USER_NOT_MESSAGABLE = 50007 + NON_TEXT_CHANNEL = 50008 + CHANNEL_VERIFICATION_TOO_HIGH = 50009 + OAUTH2_APPLICATION_BOT_AUTH_INVALID = 50010 + OAUTH2_APP_LIMIT_REACHED = 50011 + INVALID_OAUTH2_STATE = 50012 + ACTION_PERMISSION_MISSING = 50013 + INVALID_AUTH_TOKEN = 50014 + NOTE_TOO_LONG = 50015 + DELETE_OUT_OF_RANGE = 50016 + + INVALID_PIN_CHANNEL = 50019 + INVALID_INVITE_CODE = 50020 + ACTION_ON_SYSTEM_MESSAGE_PROHIBITED = 50021 + + ACTION_ON_WRONG_CHANNEL_TYPE = 50024 + OAUTH2_TOKEN_INVALID = 50025 + MISSING_OAUTH2_SCOPE = 50026 + INVALID_WEBHOOK_TOKEN = 50027 + INVALID_ROLE = 50028 + + INVALID_RECIPIENT = 50033 + MESSAGE_TOO_OLD = 50034 + INVALID_FORM_BODY = 50035 + INVITE_ACCEPTED_TO_GUILD_NOT_MEMBER = 50036 + + INVALID_API_VERSION = 50041 + + FILE_UPLOAD_SIZE_LIMIT_EXCEEDED = 50045 + INVALID_FILE_UPLOAD = 50046 + + CANNOT_SELF_REDEEM_GIFT = 50054 + INVALID_GUILD = 50055 + + INVALID_MESSAGE_TYPE = 50068 + + PAYMENT_SOURCE_REQUIRED = 50070 + + CHANNEL_REQUIRED_FOR_COMMUNITY_GUILD = 50074 + + CANNOT_EDIT_MESSAGE_STICKER = 50080 + INVALID_STICKER_SEND = 50081 + + INVALID_ACTION_ON_THREAD_ARCHIVED = 50083 + INVALID_THREAD_NOTIFICATION_SETTINGS = 50084 + BEFORE_DATE_EARLIER_THAN_THEAD_CREATION = 50085 + COMMUNITY_CHANNEL_MUST_BE_A_TEXT_CHANNEL = 50086 + + SERVER_NOT_AVAILABLE_FOR_LOCATION = 50095 + + SERVER_REQUIRED_MONETIZATION = 50097 + + SERVER_BOOTS_INSUFFICIENT = 50101 + + INVALID_JSON_BODY = 50109 + + STICKER_PERMISSION_MISSING = 50600 + TWO_FACTOR_AUTH_REQUIRED = 50603 + + DISCORD_TAG_UNALLOCATED = 80004 + + REACTION_BLOCKED = 90001 + + API_RESOURCE_OVERLOADED = 130000 + STAGE_ALREADY_OPEN = 150006 + + HISTORY_PERMISSION_MISSING_TO_REPLY = 160002 + + THREAD_ALREADY_CREATED_FOR_MESSAGE = 160004 + LOCKED_THREAD = 160005 + MAX_ACTIVE_THREADS_REACHED = 160006 + MAX_MEMBER_ACTIVE_ANNOUNCEMENTS_THREAD_REACHED = 160007 + + INVALID_JSON_LOTTIE_FILE = 170001 + RASTERIZED_IMG_IN_LOTTIE_FILE = 170002 + MAX_STICKER_FRAMERATE_REACHED = 170003 + STICKER_FRAME_COUNT_EXCEEDED = 170004 + LOTTIE_ANIMATION_DIMENSIONS_EXCEEDED = 170005 + STICKER_FRAME_RATE_OUT_OF_RANGE = 170006 + STICKER_ANIMATION_DURATION_EXCEEDED = 170007 + + CANNOT_UPLOAD_FINISHED_EVENT = 180000 + + STAGE_CREATION_ON_STAGE_EVENT_FAILED = 180002 + + @classmethod + def _missing_(cls, value: object) -> JSONErrorCodes: + return cls.GENERAL_ERROR From bbb6905135b5b0afcf62108e2ff11751985a7c4d Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Mon, 30 Jan 2023 22:07:28 +0000 Subject: [PATCH 143/335] Fix OpCode names" --- EpikCord/client/client.py | 10 +++++++++- EpikCord/client/websocket.py | 8 +++++++- EpikCord/utils/enums.py | 3 +-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index ac56c10e..4f513490 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -1,9 +1,12 @@ import asyncio from typing import Optional +from EpikCord.utils.enums import OpCode + + from ..flags import Intents from ..presence import Presence -from ..utils import cleanup_loop, singleton +from ..utils import cleanup_loop, singleton, OpCode from .http import APIVersion, HTTPClient from .websocket import WebSocketClient @@ -86,3 +89,8 @@ async def close(self): await self.ws.close() self.rate_limiter.reset.cancel() + + async def change_presence(self, presence: Presence): + await self.ws.send_json({ + "op": OpCode.STATUS_UPDATE + }) \ No newline at end of file diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 0ac2489a..ebc4fe6a 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any, DefaultDict, Dict, List, Optional, Union import aiohttp -from discord_typings import HelloData +from discord_typings import HelloData, ReadyData from ..exceptions import ClosedWebSocketConnection from ..flags import Intents @@ -68,6 +68,11 @@ def __init__(self, client: WebSocketClient): OpCode.HEARTBEAT_ACK: self.heartbeat_ack, } + async def _ready(self, data: ReadyData): + self.client.session_id = data["session_id"] + self.client.resume_url = data["resume_gateway_url"] + + def event(self): """Register an event handler. This is a decorator.""" @@ -281,6 +286,7 @@ def __init__( self.sequence: Optional[int] = None self.session_id: Optional[str] = None + self.resume_url: Optional[str] = None self.heartbeat_interval: Optional[float] = None self._heartbeats: List[int] = [] diff --git a/EpikCord/utils/enums.py b/EpikCord/utils/enums.py index aef5a70a..3cf3d033 100644 --- a/EpikCord/utils/enums.py +++ b/EpikCord/utils/enums.py @@ -7,9 +7,8 @@ class OpCode(IntEnum): DISPATCH = 0 HEARTBEAT = 1 IDENTIFY = 2 - STATUS_UPDATE = 3 + PRESENCE_UPDATE = 3 VOICE_STATE_UPDATE = 4 - VOICE_SERVER_PING = 5 RESUME = 6 RECONNECT = 7 REQUEST_GUILD_MEMBERS = 8 From cff1f9d77ccaf0fe033ef3e02bc661287fe5bc32 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 30 Jan 2023 22:08:36 +0000 Subject: [PATCH 144/335] :art: Format code automatically --- EpikCord/client/client.py | 7 ++----- EpikCord/client/websocket.py | 1 - 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index 4f513490..88bf417b 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -3,10 +3,9 @@ from EpikCord.utils.enums import OpCode - from ..flags import Intents from ..presence import Presence -from ..utils import cleanup_loop, singleton, OpCode +from ..utils import OpCode, cleanup_loop, singleton from .http import APIVersion, HTTPClient from .websocket import WebSocketClient @@ -91,6 +90,4 @@ async def close(self): self.rate_limiter.reset.cancel() async def change_presence(self, presence: Presence): - await self.ws.send_json({ - "op": OpCode.STATUS_UPDATE - }) \ No newline at end of file + await self.ws.send_json({"op": OpCode.STATUS_UPDATE}) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index d35da9af..f60310f4 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -71,7 +71,6 @@ def __init__(self, client: WebSocketClient): async def _ready(self, data: ReadyData): self.client.session_id = data["session_id"] self.client.resume_url = data["resume_gateway_url"] - def event(self): """Register an event handler. This is a decorator.""" From ee89ba004619336c25723b70815589f759a51be2 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Mon, 30 Jan 2023 23:09:15 +0100 Subject: [PATCH 145/335] :bug: Fix import --- EpikCord/client/http.py | 2 +- EpikCord/status_code.py | 0 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 EpikCord/status_code.py diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 7af26aad..b9e00613 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -17,7 +17,6 @@ Unauthorized, ) from ..file import File -from ..status_code import HTTPCodes from ..utils import ( add_file, clean_url, @@ -25,6 +24,7 @@ json_serialize, log_request, ) +from ..utils.enums import HTTPCodes from .rate_limit_tools import ( Bucket, MajorParameters, diff --git a/EpikCord/status_code.py b/EpikCord/status_code.py deleted file mode 100644 index e69de29b..00000000 From 1a7a1c8e390c058b111b75a83f435aae9d2c541b Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Mon, 30 Jan 2023 23:10:08 +0100 Subject: [PATCH 146/335] :sparkles: Add nox session for unittests --- noxfile.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/noxfile.py b/noxfile.py index fdaad8c6..33c3f180 100644 --- a/noxfile.py +++ b/noxfile.py @@ -36,6 +36,14 @@ def check_circular_imports(session: nox.Session): session.run("pytest", "tests/test_circular_imports.py") +@nox.session(name="unit") +def end_to_end(session: nox.Session): + session.install("pytest") + session.install("-r", "requirements.txt") + session.install(".") + session.run("pytest", "tests/unit") + + @nox.session(name="e2e") def end_to_end(session: nox.Session): session.install("pytest") From 2a36f8989d411e2510fa2bc2a99a7d52cd10c1ce Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Mon, 30 Jan 2023 23:11:19 +0100 Subject: [PATCH 147/335] :construction_worker: Add unittests into CI --- .github/workflows/ci.yml | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83c064d0..2300c618 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,14 +139,38 @@ jobs: - name: Check for circular imports run: nox -s imports - e2e: + unit: needs: circular_imports runs-on: ${{ matrix.os }}-latest strategy: matrix: python: [ '3.8', '3.9', '3.10' ] os: [ ubuntu, windows, macos ] - fail-fast: false + fail-fast: true + + steps: + - uses: actions/checkout@v3 + + - name: Python Setup + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python }} + cache: 'pip' + + - name: Install dependencies + run: pip install nox git+https://github.com/Bluenix2/discord-typings + + - name: Run E2E tests + run: nox -s unit + + e2e: + needs: unit + runs-on: ${{ matrix.os }}-latest + strategy: + matrix: + python: [ '3.8', '3.9', '3.10' ] + os: [ ubuntu, windows, macos ] + fail-fast: true steps: - uses: actions/checkout@v3 From 93d0066f1cdce69a547464d0f27bf9e50327f368 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Mon, 30 Jan 2023 23:28:50 +0100 Subject: [PATCH 148/335] :white_check_mark: Fix intents initialization --- EpikCord/flags.py | 19 ++++++++++++------- tests/unit/test_intents.py | 20 ++++++++++---------- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/EpikCord/flags.py b/EpikCord/flags.py index d6aa06ff..db7d1b73 100644 --- a/EpikCord/flags.py +++ b/EpikCord/flags.py @@ -12,7 +12,10 @@ def __init_subclass__(cls) -> None: } def __init__(self, value: int = 0, **kwargs): - self.turned_on: List[str] = [k for k, a in kwargs.items() if a] + self.turned_on: List[str] = [ + k.upper() for k, a in kwargs.items() if a + and self.class_flags.get(k.upper()) is not None + ] for k, v in self.class_flags.items(): if v & value and k not in self.turned_on: @@ -28,19 +31,21 @@ def value(self) -> int: def __getattribute__(self, __name: str) -> Any: original = super().__getattribute__ - key = type(self).class_flags.get(__name) + key = type(self).class_flags.get(__name.upper()) if key is None: return original(__name) return __name in original("turned_on") def __setattr__(self, __name: str, __value: Any) -> None: - if __name not in self.class_flags: + __upper_name = __name.upper() + + if __upper_name not in self.class_flags: return super().__setattr__(__name, __value) - if __value and __name not in self.turned_on: - self.turned_on.append(__name) - elif not __value and __name in self.turned_on: - self.turned_on.remove(__name) + if __value and __upper_name not in self.turned_on: + self.turned_on.append(__upper_name) + elif not __value and __upper_name in self.turned_on: + self.turned_on.remove(__upper_name) @classmethod def all(cls): diff --git a/tests/unit/test_intents.py b/tests/unit/test_intents.py index 1c60fe55..59e6a0fa 100644 --- a/tests/unit/test_intents.py +++ b/tests/unit/test_intents.py @@ -2,17 +2,17 @@ def test_intents(): - intents = Intents(guilds=True, members=True) - assert intents.guilds - assert intents.members + intents = Intents(guilds=True, guild_members=True) + assert intents.GUILDS + assert intents.GUILD_MEMBERS - assert intents.turned_on == ["guilds", "members"] - intents.guilds = False + assert intents.turned_on == ["GUILDS", "GUILD_MEMBERS"] + intents.GUILDS = False - assert intents.turned_on == ["members"] + assert intents.turned_on == ["GUILD_MEMBERS"] - intents.bans = True - assert intents.turned_on == ["members", "bans"] + intents.GUILD_MODERATION = True + assert intents.turned_on == ["GUILD_MEMBERS", "GUILD_MODERATION"] def test_intents_none(): @@ -28,7 +28,7 @@ def test_intents_all(): members = list(Intents.class_flags) assert intents.turned_on == members - intents.guilds = False + intents.GUILDS = False assert intents.turned_on == members[1:] - assert not intents.guilds + assert not intents.GUILDS From 31d38462da31a2fb88b72bd6f11bb01a947f56f1 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Mon, 30 Jan 2023 23:30:14 +0100 Subject: [PATCH 149/335] :test_tube: Add tests for status code --- tests/unit/test_status_code_enum.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 tests/unit/test_status_code_enum.py diff --git a/tests/unit/test_status_code_enum.py b/tests/unit/test_status_code_enum.py new file mode 100644 index 00000000..3e4a8821 --- /dev/null +++ b/tests/unit/test_status_code_enum.py @@ -0,0 +1,29 @@ +import pytest + +from EpikCord.utils.enums import HTTPCodes, JSONErrorCodes + + +def test_http_code_5xx(): + assert HTTPCodes(500) == HTTPCodes.SERVER_ERROR + assert HTTPCodes(502) == HTTPCodes.GATEWAY_UNAVAILABLE + + for _ in range(503, 600): + assert HTTPCodes(_) == HTTPCodes.SERVER_ERROR + + +def test_missing_code(): + with pytest.raises(ValueError): + HTTPCodes(499) + + with pytest.raises(ValueError): + HTTPCodes(600) + + +def test_invalid_code(): + with pytest.raises(ValueError): + HTTPCodes("500") + + +def test_json_general_error(): + assert JSONErrorCodes(0) == JSONErrorCodes.GENERAL_ERROR + assert JSONErrorCodes(4815162342) == JSONErrorCodes.GENERAL_ERROR From c85c9f689116bac02edcbe097e7a817f8474211c Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Mon, 30 Jan 2023 23:30:37 +0100 Subject: [PATCH 150/335] :bug: Fix server error boundaries --- EpikCord/utils/enums.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EpikCord/utils/enums.py b/EpikCord/utils/enums.py index d53db781..aa16d9f7 100644 --- a/EpikCord/utils/enums.py +++ b/EpikCord/utils/enums.py @@ -105,7 +105,7 @@ def _missing_(cls, value: object) -> HTTPCodes: if value == HTTPCodes.GATEWAY_UNAVAILABLE: return HTTPCodes.GATEWAY_UNAVAILABLE - if value > HTTPCodes.SERVER_ERROR: + if HTTPCodes.SERVER_ERROR < value < 600: return HTTPCodes.SERVER_ERROR raise ValueError( From 697cebc1876ef0b11733f62e4fadd7d76df551da Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 30 Jan 2023 22:31:12 +0000 Subject: [PATCH 151/335] :art: Format code automatically --- EpikCord/flags.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/EpikCord/flags.py b/EpikCord/flags.py index db7d1b73..2e7bcbd4 100644 --- a/EpikCord/flags.py +++ b/EpikCord/flags.py @@ -13,8 +13,9 @@ def __init_subclass__(cls) -> None: def __init__(self, value: int = 0, **kwargs): self.turned_on: List[str] = [ - k.upper() for k, a in kwargs.items() if a - and self.class_flags.get(k.upper()) is not None + k.upper() + for k, a in kwargs.items() + if a and self.class_flags.get(k.upper()) is not None ] for k, v in self.class_flags.items(): From ebc40a7cdadba9dd8cc1248fbd45cd85267c399c Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Mon, 30 Jan 2023 23:35:31 +0100 Subject: [PATCH 152/335] :zap: Make CI faster by running unit & e2e as same time --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2300c618..651a8145 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -164,7 +164,7 @@ jobs: run: nox -s unit e2e: - needs: unit + needs: circular_imports runs-on: ${{ matrix.os }}-latest strategy: matrix: @@ -188,7 +188,7 @@ jobs: run: nox -s e2e coverage: - needs: e2e + needs: unit e2e runs-on: ubuntu-latest steps: From a90c4e278e13950c4b7989c7321e1a9d62187779 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Mon, 30 Jan 2023 23:37:50 +0100 Subject: [PATCH 153/335] :green_heart: Fix yml syntax --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 651a8145..73c30f53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -188,7 +188,7 @@ jobs: run: nox -s e2e coverage: - needs: unit e2e + needs: [unit, e2e] runs-on: ubuntu-latest steps: From 454a658e5306d4ef36a751e76cca5ad94c71b9b5 Mon Sep 17 00:00:00 2001 From: Yohann Boniface Date: Tue, 31 Jan 2023 23:35:40 +0100 Subject: [PATCH 154/335] :recycle: Fix Import --- EpikCord/client/http.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index b9e00613..9ac1585b 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -24,7 +24,7 @@ json_serialize, log_request, ) -from ..utils.enums import HTTPCodes +from ..utils import HTTPCodes from .rate_limit_tools import ( Bucket, MajorParameters, From 8688896d7f19a7ab02c8ebeb14068c35e38dd7cf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 31 Jan 2023 22:36:17 +0000 Subject: [PATCH 155/335] :art: Format code automatically --- EpikCord/client/http.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 9ac1585b..2c62fd47 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -18,13 +18,13 @@ ) from ..file import File from ..utils import ( + HTTPCodes, add_file, clean_url, extract_content, json_serialize, log_request, ) -from ..utils import HTTPCodes from .rate_limit_tools import ( Bucket, MajorParameters, From cb687c22488db0d2b9b6be079d7f1619930aa717 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Wed, 1 Feb 2023 00:11:40 +0100 Subject: [PATCH 156/335] :adhesive_bandage: Fix __init__ import --- EpikCord/utils/__init__.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py index 7ccf34ed..fe5ec59e 100644 --- a/EpikCord/utils/__init__.py +++ b/EpikCord/utils/__init__.py @@ -1,4 +1,11 @@ -from .enums import GatewayCloseCode, OpCode, VoiceCloseCode, VoiceOpCode +from .enums import ( + GatewayCloseCode, + HTTPCodes, + JSONErrorCodes, + OpCode, + VoiceCloseCode, + VoiceOpCode +) from .loose import ( add_file, cancel_tasks, @@ -18,6 +25,8 @@ ) __all__ = ( + "HTTPCodes", + "JSONErrorCodes", "AsyncFunction", "GatewayCloseCode", "OpCode", From ac6cad6c583f6d123def419226212ad62bb5eda0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 31 Jan 2023 23:12:15 +0000 Subject: [PATCH 157/335] :art: Format code automatically --- EpikCord/utils/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py index fe5ec59e..cb2d5c4f 100644 --- a/EpikCord/utils/__init__.py +++ b/EpikCord/utils/__init__.py @@ -4,7 +4,7 @@ JSONErrorCodes, OpCode, VoiceCloseCode, - VoiceOpCode + VoiceOpCode, ) from .loose import ( add_file, From ecdb3a421f647226a12609f46430b770fbc07ae4 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Wed, 1 Feb 2023 20:42:00 +0000 Subject: [PATCH 158/335] Current state --- EpikCord/client/client.py | 5 ++++- EpikCord/client/websocket.py | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index 4f513490..82b67951 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -91,6 +91,9 @@ async def close(self): self.rate_limiter.reset.cancel() async def change_presence(self, presence: Presence): + if not self.ws: + raise RuntimeError("Client is not connected to the gateway.") await self.ws.send_json({ - "op": OpCode.STATUS_UPDATE + "op": OpCode.PRESENCE_UPDATE, + "d": presence.to_dict() }) \ No newline at end of file diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index d35da9af..a6175fc4 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -71,7 +71,8 @@ def __init__(self, client: WebSocketClient): async def _ready(self, data: ReadyData): self.client.session_id = data["session_id"] self.client.resume_url = data["resume_gateway_url"] - + + def event(self): """Register an event handler. This is a decorator.""" From 9e5ca57e9099a1be70274c3118e1c95acaefe9c0 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Wed, 1 Feb 2023 22:16:41 +0000 Subject: [PATCH 159/335] Standardise line length of 80 --- pyproject.toml | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ffb29698..572fb970 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools"] build-backend = "setuptools.build_meta" [tool.black] -line-length = 79 +line-length = 80 target-version = ['py38'] [tool.pyright] diff --git a/setup.cfg b/setup.cfg index eede20d3..187430c9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -18,7 +18,7 @@ author_email = support@epikhost.xyz url = https://github.com/EpikCord/EpikCord.py [flake8] -max-line-length = 88 +max-line-length = 80 [options] zip_safe = False From bf589edf572dfa9dff72e80623580457c82adeab Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 1 Feb 2023 22:18:51 +0000 Subject: [PATCH 160/335] :art: Format code automatically --- EpikCord/client/client.py | 7 +++---- EpikCord/client/rate_limit_tools.py | 1 - EpikCord/client/websocket.py | 1 - EpikCord/presence.py | 4 +--- 4 files changed, 4 insertions(+), 9 deletions(-) diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index 24d08884..d059ce08 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -92,7 +92,6 @@ async def close(self): async def change_presence(self, presence: Presence): if not self.ws: raise RuntimeError("Client is not connected to the gateway.") - await self.ws.send_json({ - "op": OpCode.PRESENCE_UPDATE, - "d": presence.to_dict() - }) + await self.ws.send_json( + {"op": OpCode.PRESENCE_UPDATE, "d": presence.to_dict()} + ) diff --git a/EpikCord/client/rate_limit_tools.py b/EpikCord/client/rate_limit_tools.py index 275c88cf..4c8de163 100644 --- a/EpikCord/client/rate_limit_tools.py +++ b/EpikCord/client/rate_limit_tools.py @@ -13,7 +13,6 @@ class GatewayRateLimiter: def __init__(self): - self.event = asyncio.Event() self.event.set() diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index f60310f4..33a12173 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -244,7 +244,6 @@ async def receive(self, *args, **kwargs): message = ws_message.data if isinstance(message, bytes): - self.buffer.extend(message) if len(message) < 4 or message[-4:] != b"\x00\x00\xff\xff": diff --git a/EpikCord/presence.py b/EpikCord/presence.py index 7656eb1c..cb1d3152 100644 --- a/EpikCord/presence.py +++ b/EpikCord/presence.py @@ -4,9 +4,7 @@ from typing_extensions import NotRequired -class ActivityPayload( - TypedDict -): # Data sent to Discord when updating presence +class ActivityPayload(TypedDict): # Data sent to Discord when updating presence name: str type: int url: NotRequired[str] From b93883855c7c198ee964f9a043b274a2b431eb4f Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Tue, 7 Feb 2023 10:35:42 +0000 Subject: [PATCH 161/335] Fix docstrings --- EpikCord/client/http.py | 12 ++++++------ EpikCord/client/rate_limit_tools.py | 21 ++++++++++----------- EpikCord/client/websocket.py | 2 +- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 2c62fd47..ba974803 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -41,7 +41,7 @@ class APIVersion(IntEnum): - """Represents the version of the Discord HTTP API and Gateway WebSocket to use.""" + """The version of the Discord HTTP API and Gateway WebSocket to use.""" NINE = 9 TEN = 10 @@ -183,7 +183,7 @@ async def set_bucket( response: aiohttp.ClientResponse, major_parameters: Optional[MajorParameters] = None, ) -> Union[Bucket, TopLevelBucket]: - """Sets the bucket for the request. + """Set the bucket for the request. Parameters ---------- @@ -227,7 +227,7 @@ async def handle_ratelimit( data: Dict[str, Any], bucket: Union[Bucket, TopLevelBucket, MockBucket], ): - """Handles the ratelimit for the request. + """Handle the ratelimit for the request. Parameters ---------- @@ -256,7 +256,7 @@ def setup_request_kwargs( files: Optional[List[File]] = None, json: Optional[Dict] = None, ) -> Optional[Dict]: - """Sets up the request kwargs. + """Set up the request kwargs. Parameters ---------- @@ -292,11 +292,11 @@ def setup_request_kwargs( return return_kwargs async def close(self) -> None: - """Closes the session.""" + """Close the session.""" await self.session.close() async def get_gateway(self) -> str: - """Gets the URL used for connecting to the Gateway. + """Get the URL used for connecting to the Gateway. Returns ------- diff --git a/EpikCord/client/rate_limit_tools.py b/EpikCord/client/rate_limit_tools.py index 7b2efa0c..0a8064a9 100644 --- a/EpikCord/client/rate_limit_tools.py +++ b/EpikCord/client/rate_limit_tools.py @@ -35,22 +35,22 @@ class MockBucket: """A mock bucket that does nothing.""" async def wait(self): - """Pretends to wait for the Event to be set""" + """Pretend to wait for the Event to be set.""" ... def clear(self): - """Pretends to clear the Event""" + """Pretend to clear the Event.""" ... def set(self): - """Pretends to set the Event""" + """Pretend to set the Event.""" ... def __eq__(self, other: Any) -> bool: return isinstance(other, MockBucket) async def handle_exhaustion(self, retry_after: int): - """Pretends to handle the exhaustion of the bucket""" + """Pretend to handle the exhaustion of the bucket.""" ... @@ -176,7 +176,7 @@ def __init__( async def wait(self): """ - Waits for the Event to be set. + Wait for the Event to be set. """ logger.info(f"Waiting for bucket {self.major_parameters} to be set.") await self.event.wait() @@ -184,14 +184,14 @@ async def wait(self): def set(self): """ - Sets the Event. + Set the Event. """ logger.info(f"Setting bucket {self.major_parameters}.") self.event.set() def clear(self): """ - Clears the Event. + Clear the Event. """ logger.info(f"Clearing bucket {self.major_parameters}.") self.event.clear() @@ -209,7 +209,7 @@ def __str__(self) -> str: async def handle_exhaustion(self, retry_after: int): """ - Handles the exhaustion of the bucket. + Handle the exhaustion of the bucket. Parameters ---------- @@ -248,8 +248,7 @@ def __init__( webhook_id: Optional[int] = None, webhook_token: Optional[str] = None, ): - """A class to group up all the major parameters of a route. - + """ Parameters ---------- channel_id: Optional[int] @@ -281,7 +280,7 @@ def __eq__(self, other: Any): class Route: - """Represents a HTTP route.""" + """Represent a HTTP route.""" def __init__( self, diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index a968b50f..5c84535c 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -88,7 +88,7 @@ async def dispatch(self, event_name: str, *args, **kwargs): asyncio.create_task(event(*args, **kwargs)) @staticmethod - async def heartbeat_ack(self, _: Any): + async def heartbeat_ack(_: Any): """Handle the heartbeat ack event. OpCode 11.""" ... From 70041758ec59ad2b0b8c42262ab6b4ad070135c2 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Tue, 7 Feb 2023 10:52:12 +0000 Subject: [PATCH 162/335] Wrap all OpCodes (apart from DISPATCH) --- EpikCord/client/websocket.py | 52 ++++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 5c84535c..25aa8b3f 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -62,16 +62,14 @@ def __init__(self, client: WebSocketClient): list ) self.events: DefaultDict[str, List[AsyncFunction]] = defaultdict(list) - self.event_mapping: Dict[OpCode, AsyncFunction] = { - OpCode.HELLO: self.hello, + self.opcode_mapping: Dict[OpCode, AsyncFunction] = { OpCode.HEARTBEAT: partial(self.heartbeat, forced=True), + OpCode.RECONNECT: self.reconnect, + OpCode.INVALID_SESSION: self.invalid_session, + OpCode.HELLO: self.hello, OpCode.HEARTBEAT_ACK: self.heartbeat_ack, } - async def _ready(self, data: ReadyData): - self.client.session_id = data["session_id"] - self.client.resume_url = data["resume_gateway_url"] - def event(self): """Register an event handler. This is a decorator.""" @@ -126,6 +124,10 @@ async def send_json(self, payload: Any): logger.debug("Sending %s to Gateway", payload) await self.client.ws.send_json(payload) + async def _ready(self, data: ReadyData): + self.client.session_id = data["session_id"] + self.client.resume_url = data["resume_gateway_url"] + async def identify(self): payload: IdentifyCommand = { "op": OpCode.IDENTIFY, @@ -186,6 +188,37 @@ async def heartbeat(self, *, forced: Optional[bool] = False): self.client._heartbeats.append(end - start) + + async def resume(self): + await self.send_json({ + "op": OpCode.RESUME, + "d": { + "seq": self.client.sequence, + "session_id": self.client.session_id, + } + }) + + async def reconnect(self, _): + if self.client.ws: + await self.client.ws.close() + await self.client.connect() + if self.client.session_id and self.client.sequence: + await self.resume() + + async def resumed(self, _): + logger.info(f"Resumed session {self.client.session_id}") + + async def invalid_session(self, resumable: bool): + if self.client.ws: + await self.client.ws.close() + + await self.client.connect() + + if resumable: + await self.resume() + else: + await self.identify() + async def handle(self, message: DiscordWSMessage): try: event = message.json() @@ -211,8 +244,8 @@ async def handle(self, message: DiscordWSMessage): self.wait_for_events[value].remove(wait_for_event) if event["op"] != OpCode.DISPATCH: - if event["op"] in self.event_mapping: - await self.event_mapping[event["op"]](event["d"]) + if event["op"] in self.opcode_mapping: + await self.opcode_mapping[event["op"]](event["d"]) else: logger.error("Unhandled opcode %s", event["op"]) else: @@ -220,9 +253,6 @@ async def handle(self, message: DiscordWSMessage): event["t"].lower(), event["d"] ) # TODO: Once we have completed the HTTP objects, we can then start to transform them before they reach the end user. - async def resume(self): - ... - class DiscordWSMessage: def __init__(self, *, data, msg_type, extra): From 03a6d2c8b799366fa08b1903c294115f5c35595a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 7 Feb 2023 10:52:43 +0000 Subject: [PATCH 163/335] :art: Format code automatically --- EpikCord/client/websocket.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 25aa8b3f..7194f7a1 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -188,15 +188,16 @@ async def heartbeat(self, *, forced: Optional[bool] = False): self.client._heartbeats.append(end - start) - async def resume(self): - await self.send_json({ - "op": OpCode.RESUME, - "d": { - "seq": self.client.sequence, - "session_id": self.client.session_id, + await self.send_json( + { + "op": OpCode.RESUME, + "d": { + "seq": self.client.sequence, + "session_id": self.client.session_id, + }, } - }) + ) async def reconnect(self, _): if self.client.ws: @@ -204,7 +205,7 @@ async def reconnect(self, _): await self.client.connect() if self.client.session_id and self.client.sequence: await self.resume() - + async def resumed(self, _): logger.info(f"Resumed session {self.client.session_id}") From de2b36c0fd08405033c5582c57a1aadc9ce3c626 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Tue, 7 Feb 2023 10:54:55 +0000 Subject: [PATCH 164/335] Remove check for if opcode is registered --- EpikCord/client/websocket.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 25aa8b3f..90925680 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -244,16 +244,14 @@ async def handle(self, message: DiscordWSMessage): self.wait_for_events[value].remove(wait_for_event) if event["op"] != OpCode.DISPATCH: - if event["op"] in self.opcode_mapping: - await self.opcode_mapping[event["op"]](event["d"]) - else: - logger.error("Unhandled opcode %s", event["op"]) + await self.opcode_mapping[event["op"]](event["d"]) else: await self.dispatch( event["t"].lower(), event["d"] ) # TODO: Once we have completed the HTTP objects, we can then start to transform them before they reach the end user. + class DiscordWSMessage: def __init__(self, *, data, msg_type, extra): self.data = data From 7efd7f7b437349483f441b016be27fd40a1e7fc9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 7 Feb 2023 10:55:29 +0000 Subject: [PATCH 165/335] :art: Format code automatically --- EpikCord/client/websocket.py | 1 - 1 file changed, 1 deletion(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 6a360b45..b6eb1d28 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -252,7 +252,6 @@ async def handle(self, message: DiscordWSMessage): ) # TODO: Once we have completed the HTTP objects, we can then start to transform them before they reach the end user. - class DiscordWSMessage: def __init__(self, *, data, msg_type, extra): self.data = data From 528367aa209a7f64e3116f10958245955e80ded1 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Thu, 9 Feb 2023 22:08:39 +0100 Subject: [PATCH 166/335] :rotating_light: Fix linter issues --- EpikCord/client/client.py | 6 ++---- EpikCord/client/websocket.py | 5 ++++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index fbd7d0c4..b105bcc8 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -1,13 +1,11 @@ import asyncio from typing import Optional -from EpikCord.utils.enums import OpCode - +from .http import APIVersion, HTTPClient +from .websocket import WebSocketClient from ..flags import Intents from ..presence import Presence from ..utils import OpCode, cleanup_loop, singleton -from .http import APIVersion, HTTPClient -from .websocket import WebSocketClient @singleton diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index b6eb1d28..22fe854a 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -247,9 +247,12 @@ async def handle(self, message: DiscordWSMessage): if event["op"] != OpCode.DISPATCH: await self.opcode_mapping[event["op"]](event["d"]) else: + # TODO: Once we have completed the HTTP objects, + # we can then start to transform them before they reach + # the end user. await self.dispatch( event["t"].lower(), event["d"] - ) # TODO: Once we have completed the HTTP objects, we can then start to transform them before they reach the end user. + ) class DiscordWSMessage: From 504b59863c78d7c5c4d26be2f923f9165900e435 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 9 Feb 2023 21:09:18 +0000 Subject: [PATCH 167/335] :art: Format code automatically --- EpikCord/client/client.py | 4 ++-- EpikCord/client/websocket.py | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index b105bcc8..f41aa153 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -1,11 +1,11 @@ import asyncio from typing import Optional -from .http import APIVersion, HTTPClient -from .websocket import WebSocketClient from ..flags import Intents from ..presence import Presence from ..utils import OpCode, cleanup_loop, singleton +from .http import APIVersion, HTTPClient +from .websocket import WebSocketClient @singleton diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 22fe854a..fcfe0609 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -250,9 +250,7 @@ async def handle(self, message: DiscordWSMessage): # TODO: Once we have completed the HTTP objects, # we can then start to transform them before they reach # the end user. - await self.dispatch( - event["t"].lower(), event["d"] - ) + await self.dispatch(event["t"].lower(), event["d"]) class DiscordWSMessage: From b4698fc926f7662cef73b03d855f65deef6d5205 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Thu, 9 Feb 2023 22:39:24 +0100 Subject: [PATCH 168/335] :recycle: Use HTTPCode enum --- EpikCord/client/http.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index ba974803..2dd9b413 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -50,11 +50,11 @@ class APIVersion(IntEnum): class HTTPClient: """The HTTPClient used to make requests to the Discord API.""" - error_mapping: Dict[int, Type[HTTPException]] = { - 400: BadRequest, - 401: Unauthorized, - 403: Forbidden, - 404: NotFound, + error_mapping: Dict[HTTPCodes, Type[HTTPException]] = { + HTTPCodes.BAD_REQUEST: BadRequest, + HTTPCodes.UNAUTHORIZED: Unauthorized, + HTTPCodes.FORBIDDEN: Forbidden, + HTTPCodes.NOT_FOUND: NotFound, } def __init__( @@ -168,7 +168,7 @@ async def request( ) elif not response.ok: error = self.error_mapping.get( - response.status, HTTPException + HTTPCodes(response.status), HTTPException ) raise error(data) elif response.ok: @@ -201,8 +201,6 @@ async def set_bucket( method = response.method bucket_key: str = f"{method}:{url}" - bucket: Optional[Union[Bucket, TopLevelBucket]] = None - if major_parameters: bucket = TopLevelBucket( channel_id=major_parameters.channel_id, @@ -237,7 +235,7 @@ async def handle_ratelimit( The bucket for the request. """ logger.critical( - f"Ratelimited bucket {bucket} for {data['retry_after']} seconds." + f"Rate-limited bucket {bucket} for {data['retry_after']} seconds." ) bucket.clear() From 2b0ca4ed93c5f5fd25c2ce6529c3eb5cb57a73e5 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Thu, 9 Feb 2023 22:39:55 +0100 Subject: [PATCH 169/335] :rotating_light: Fix requirements.txt --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 8646daad..52868a5b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ aiohttp -discord_typings \ No newline at end of file +git+https://github.com/Bluenix2/discord-typings.git From eccca4b03ef49b45a61ecec8f0e2b3c64d75152f Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Thu, 9 Feb 2023 23:08:57 +0100 Subject: [PATCH 170/335] :arrow_up: Add dev requirements --- dev.requirements.txt | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 dev.requirements.txt diff --git a/dev.requirements.txt b/dev.requirements.txt new file mode 100644 index 00000000..b8dde9d0 --- /dev/null +++ b/dev.requirements.txt @@ -0,0 +1,8 @@ +black +coverage +flake8 +isort +nox +pyright +pytest +pytest-cov From e1550a93347acb421ca494fb49f4a727a90cbba9 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Fri, 10 Feb 2023 00:03:22 +0100 Subject: [PATCH 171/335] :hammer: Rewrite Makefile --- .gitignore | 5 +++- Makefile | 77 ++++++++++++++++++++++++++++++++++-------------------- noxfile.py | 2 +- 3 files changed, 53 insertions(+), 31 deletions(-) diff --git a/.gitignore b/.gitignore index 1ac93f74..635db9b8 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,7 @@ epikcord.log # Coverage .coverage -coverage.xml \ No newline at end of file +coverage.xml + +# Makefile deps +._*.lock diff --git a/Makefile b/Makefile index 00085a71..0af44f65 100644 --- a/Makefile +++ b/Makefile @@ -4,49 +4,68 @@ TEST_DIR = tests PY_ENV = venv PY_BIN = $(PY_ENV)/bin -all: reqs +PIP = $(PY_BIN)/pip +CMD_NOT_FOUND = $(error $(strip $(1)) is required for this rule) +CHECK_CMD = $(if $(shell command -v $(1)),, $(call CMD_NOT_FOUND, $(1))) -$(PY_BIN)/%: - python3 -m venv $(PY_ENV) - chmod +x $(PY_BIN)/activate - ./$(PY_BIN)/activate +SRC = $(shell find $(SRC_DIR) -type f -name "*.py") -reqs: $(PY_BIN)/pip - $(PY_BIN)/pip3 install -e . +REQS = ._deps.lock +FORMAT_CODE = ._format_code.lock +all: $(REQS) $(FORMAT_CODE) -$(PY_BIN)/nox: $(PY_BIN)/pip - $(PY_BIN)/pip3 install nox +$(PY_ENV): + $(call CHECK_CMD, python3) + @ python3 -m venv $@ +$(REQS): $(PY_ENV) + $(call CHECK_CMD, $(PIP)) + @ $(PIP) install -e . + @ $(PIP) install -r requirements.txt + @ $(PIP) install -r dev.requirements.txt + @ touch $@ -lint: $(PY_BIN)/nox - $(PY_BIN)/nox -s lint mypy +$(FORMAT_CODE): $(SRC) + @ $(info $(words $?) files changed since last auto-format) + @ $(PY_BIN)/black $(SRC_DIR) + @ touch $@ +NOX_RULES := install +NOX_RULES += format +NOX_RULES += lint +NOX_RULES += pyright +NOX_RULES += imports +NOX_RULES += unit +NOX_RULES += e2e +NOX_RULES += cov -pretty: $(PY_BIN)/nox - $(PY_BIN)/nox -s format +define _CREATE_NR +$(2): $(REQS) + $(PY_BIN)/nox -s $(1) +.PHONY: $(2) +endef -dist: reqs - python setup.py build sdist +CREATE_NOX_RULE = $(eval $(call _CREATE_NR, $(1), $(addprefix nox_, $(1)))) +$(foreach nr, $(NOX_RULES), $(call CREATE_NOX_RULE, $(nr))) +nox_all: $(foreach nr, $(NOX_RULES), $(addprefix nox_, $(nr)))) -clean: - rm -rf */__pycache__ - rm -rf .pytest_cache - rm -rf .mypy_cache - - rm -rf dist - rm -rf build - - rm -rf *.egg-info +.PHONY: nox_all +NOX_DIR = .nox +BUILD_DIRS = build *.egg-info +PY_TEST_CACHE = .pytest_cache -fclean: clean - rm -rf $(PY_ENV) - rm -rf .nox - +clean: + @ find $(SRC_DIR) -type f -name "*.pyc" -exec rm -rf {} + + @ $(RM) -r $(NOX_DIR) $(BUILD_DIRS) + @ $(RM) $(REQS) $(FORMAT_CODE) + @ $(RM) -r $(PY_TEST_CACHE) -.PHONY: py_test clean fclean reqs lint pretty +fclean: + @ $(RM) -r $(VENV) +.PHONY: clean fclean diff --git a/noxfile.py b/noxfile.py index 33c3f180..36fa8e2e 100644 --- a/noxfile.py +++ b/noxfile.py @@ -52,7 +52,7 @@ def end_to_end(session: nox.Session): session.run("pytest", "tests/e2e") -@nox.session +@nox.session(name="cov") def coverage_report(session: nox.Session): session.install("pytest", "pytest-cov", "coverage") session.install("-r", "requirements.txt") From 1b0753c59e118b1554f111efbe49752236092eb7 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Fri, 10 Feb 2023 00:11:22 +0100 Subject: [PATCH 172/335] :hammer: Forget re rule --- Makefile | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 0af44f65..b51c61eb 100644 --- a/Makefile +++ b/Makefile @@ -65,7 +65,11 @@ clean: @ $(RM) $(REQS) $(FORMAT_CODE) @ $(RM) -r $(PY_TEST_CACHE) -fclean: +fclean: clean @ $(RM) -r $(VENV) .PHONY: clean fclean + +re: fclean all + +.PHONY: re From fb352f60d29b2f8914f5d8b1d28723ca29eafa6a Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Fri, 10 Feb 2023 00:19:56 +0100 Subject: [PATCH 173/335] :hammer: That parenthesis --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index b51c61eb..3b360280 100644 --- a/Makefile +++ b/Makefile @@ -51,7 +51,7 @@ endef CREATE_NOX_RULE = $(eval $(call _CREATE_NR, $(1), $(addprefix nox_, $(1)))) $(foreach nr, $(NOX_RULES), $(call CREATE_NOX_RULE, $(nr))) -nox_all: $(foreach nr, $(NOX_RULES), $(addprefix nox_, $(nr)))) +nox_all: $(foreach nr, $(NOX_RULES), $(addprefix nox_, $(nr))) .PHONY: nox_all From 006fb4d7eb26399ce31fd76026da47918c92d70d Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sun, 12 Feb 2023 18:13:44 +0000 Subject: [PATCH 174/335] Improve logging --- EpikCord/client/websocket.py | 22 +++++++++++++++++----- EpikCord/utils/enums.py | 6 +++--- EpikCord/utils/loose.py | 5 +++-- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index b6eb1d28..b38cba35 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -312,6 +312,8 @@ def __init__( self.rate_limiter: GatewayRateLimiter = GatewayRateLimiter() self.event_handler: GatewayEventHandler = GatewayEventHandler(self) + self._gateway_url: Optional[str] = None + self.sequence: Optional[int] = None self.session_id: Optional[str] = None self.resume_url: Optional[str] = None @@ -324,16 +326,26 @@ def latency(self) -> Optional[float]: return None return sum(self._heartbeats) / len(self._heartbeats) - async def connect(self): - url = await self.http.get_gateway() + async def connect(self, *, resume: bool = False): + if not self._gateway_url and not resume: + self._gateway_url = await self.http.get_gateway() + version = self.http.version.value + + url = f"{self._gateway_url}?v={version}&encoding=json&compress=zlib-stream" if not resume else self.resume_url + + if not self.resume_url and resume: + raise ValueError("Cannot resume without a resume url") + elif not url: + raise ValueError(f"Cannot connect to url {url} (resume: {resume})") + + self.ws = await self.http.ws_connect(url) asyncio.create_task(self.rate_limiter.reset.start()) - self.ws = await self.http.ws_connect( - f"{url}?v={version}&encoding=json&compress=zlib-stream" - ) + async for message in self.ws: logger.debug("Received message: %s", message.json()) await self.event_handler.handle(message) # type: ignore + await self.handle_close() async def handle_close(self): diff --git a/EpikCord/utils/enums.py b/EpikCord/utils/enums.py index 59eec936..367697ea 100644 --- a/EpikCord/utils/enums.py +++ b/EpikCord/utils/enums.py @@ -1,6 +1,7 @@ from __future__ import annotations from enum import IntEnum +from logging import getLogger class OpCode(IntEnum): @@ -101,9 +102,6 @@ def _missing_(cls, value: object) -> HTTPCodes: if not isinstance(value, int): raise ValueError(f"{value} is not a valid HTTP status code.") - if value == HTTPCodes.GATEWAY_UNAVAILABLE: - return HTTPCodes.GATEWAY_UNAVAILABLE - if HTTPCodes.SERVER_ERROR < value < 600: return HTTPCodes.SERVER_ERROR @@ -339,4 +337,6 @@ class JSONErrorCodes(StatusCode): @classmethod def _missing_(cls, value: object) -> JSONErrorCodes: + logger = getLogger("EpikCord.exceptions") + logger.warning(f"Unknown JSON error code: {value}") return cls.GENERAL_ERROR diff --git a/EpikCord/utils/loose.py b/EpikCord/utils/loose.py index 0517a323..32d62142 100644 --- a/EpikCord/utils/loose.py +++ b/EpikCord/utils/loose.py @@ -26,8 +26,6 @@ if TYPE_CHECKING: from . import SendingAttachmentData -logger = getLogger("EpikCord.utils") - def clear_none_values(d: dict): """ @@ -77,6 +75,7 @@ def wrapper(*args, **kwargs): def cancel_tasks(loop) -> None: + logger = getLogger("EpikCord.cleanup") tasks = {t for t in asyncio.all_tasks(loop=loop) if not t.done()} if not tasks: @@ -89,6 +88,7 @@ def cancel_tasks(loop) -> None: def cleanup_loop(loop) -> None: + logger = getLogger("EpikCord.cleanup") try: cancel_tasks(loop) logger.debug("Shutting down async generators.") @@ -99,6 +99,7 @@ def cleanup_loop(loop) -> None: async def log_request(res, body: Optional[dict] = None): """Logs information about the request.""" + logger = getLogger("EpikCord.http") messages = [ f"Sent a {res.method} to {res.url} " f"and got a {res.status} response. ", From d4ae0f89c68b2612d8bb17375c77cb23c524f9f1 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sun, 12 Feb 2023 19:23:55 +0100 Subject: [PATCH 175/335] :recycle: Improve HTTP missing check --- EpikCord/utils/enums.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/EpikCord/utils/enums.py b/EpikCord/utils/enums.py index 59eec936..bde33ac1 100644 --- a/EpikCord/utils/enums.py +++ b/EpikCord/utils/enums.py @@ -1,6 +1,7 @@ from __future__ import annotations from enum import IntEnum +from typing import Final class OpCode(IntEnum): @@ -76,6 +77,9 @@ class StatusCode(IntEnum): pass +_SERVER_ERROR_BOUND: Final[int] = 600 + + class HTTPCodes(StatusCode): """HTTP response codes.""" @@ -101,10 +105,7 @@ def _missing_(cls, value: object) -> HTTPCodes: if not isinstance(value, int): raise ValueError(f"{value} is not a valid HTTP status code.") - if value == HTTPCodes.GATEWAY_UNAVAILABLE: - return HTTPCodes.GATEWAY_UNAVAILABLE - - if HTTPCodes.SERVER_ERROR < value < 600: + if HTTPCodes.SERVER_ERROR < value < _SERVER_ERROR_BOUND: return HTTPCodes.SERVER_ERROR raise ValueError( From 2660f54c5f32bb7634d595c6a0bc4158170cea2a Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sun, 12 Feb 2023 18:26:02 +0000 Subject: [PATCH 176/335] Improve logging --- EpikCord/client/websocket.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index b38cba35..cf7ab069 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -202,7 +202,7 @@ async def resume(self): async def reconnect(self, _): if self.client.ws: await self.client.ws.close() - await self.client.connect() + await self.client.connect(resume=True) if self.client.session_id and self.client.sequence: await self.resume() @@ -213,7 +213,7 @@ async def invalid_session(self, resumable: bool): if self.client.ws: await self.client.ws.close() - await self.client.connect() + await self.client.connect(resume=resumable) if resumable: await self.resume() From 85d398e26446a59143ee7132655a044bccb7f587 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sun, 12 Feb 2023 18:30:45 +0000 Subject: [PATCH 177/335] Handle event better --- EpikCord/client/websocket.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 04e03f5d..16483dd7 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -128,6 +128,12 @@ async def _ready(self, data: ReadyData): self.client.session_id = data["session_id"] self.client.resume_url = data["resume_gateway_url"] + await self.dispatch("ready", data) + + async def _resumed(self, _: Any): + logger.info("Resumed session %s", self.client.session_id) + await self.dispatch("resumed", self.client.session_id) + async def identify(self): payload: IdentifyCommand = { "op": OpCode.IDENTIFY, @@ -206,9 +212,6 @@ async def reconnect(self, _): if self.client.session_id and self.client.sequence: await self.resume() - async def resumed(self, _): - logger.info(f"Resumed session {self.client.session_id}") - async def invalid_session(self, resumable: bool): if self.client.ws: await self.client.ws.close() From 5970bad9faea7efeca21597ecaf5b6d1db76683f Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sun, 12 Feb 2023 19:31:26 +0100 Subject: [PATCH 178/335] :bug: Fix CI prettifier --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73c30f53..e601b292 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: - name: run Formatter run: | - make pretty + make nox_format - name: Push changes run: | From f49167666b2a1f68b36535da0c8934acb9aa6b8f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 12 Feb 2023 18:32:15 +0000 Subject: [PATCH 179/335] :art: Format code automatically --- EpikCord/client/websocket.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 16483dd7..91331500 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -129,7 +129,7 @@ async def _ready(self, data: ReadyData): self.client.resume_url = data["resume_gateway_url"] await self.dispatch("ready", data) - + async def _resumed(self, _: Any): logger.info("Resumed session %s", self.client.session_id) await self.dispatch("resumed", self.client.session_id) @@ -336,7 +336,11 @@ async def connect(self, *, resume: bool = False): version = self.http.version.value - url = f"{self._gateway_url}?v={version}&encoding=json&compress=zlib-stream" if not resume else self.resume_url + url = ( + f"{self._gateway_url}?v={version}&encoding=json&compress=zlib-stream" + if not resume + else self.resume_url + ) if not self.resume_url and resume: raise ValueError("Cannot resume without a resume url") From 440e79f8712f8db1b25d4e401374dd5b92aeadc8 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sun, 12 Feb 2023 20:03:28 +0100 Subject: [PATCH 180/335] :recycle: Remove magic constant for flags --- EpikCord/flags.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/EpikCord/flags.py b/EpikCord/flags.py index 2e7bcbd4..6f401ae9 100644 --- a/EpikCord/flags.py +++ b/EpikCord/flags.py @@ -1,6 +1,8 @@ from __future__ import annotations -from typing import Any, Dict, List +from typing import Any, Dict, List, Final + +ALL_VALUE_DISABLED: Final[int] = 0 class Flag: @@ -11,7 +13,7 @@ def __init_subclass__(cls) -> None: k: v for k, v in cls.__dict__.items() if isinstance(v, int) } - def __init__(self, value: int = 0, **kwargs): + def __init__(self, value: int = ALL_VALUE_DISABLED, **kwargs): self.turned_on: List[str] = [ k.upper() for k, a in kwargs.items() From 4b75b8e1713a32eb1d4a53a54cff51bd353f36ba Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sun, 12 Feb 2023 20:03:45 +0100 Subject: [PATCH 181/335] :recycle: Remove magic constant for tasks --- EpikCord/ext/tasks.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/EpikCord/ext/tasks.py b/EpikCord/ext/tasks.py index 4c97a7c9..a7820b91 100644 --- a/EpikCord/ext/tasks.py +++ b/EpikCord/ext/tasks.py @@ -1,7 +1,10 @@ import asyncio import typing from datetime import timedelta -from typing import Callable, Coroutine +from typing import Callable, Coroutine, Final + + +INFINITE_RUNS: Final[int] = -1 class Task: @@ -14,18 +17,22 @@ def __init__( self.wrapped_func: Callable[..., Coroutine] = wrapped_func self.duration: float = duration self.max_runs: int = max_runs - self.runs: int = 0 + self.runs_count: int = INFINITE_RUNS self._task: typing.Optional[asyncio.Task] = None async def start(self, *args: typing.Any, **kwargs: typing.Any): - while self.runs < self.max_runs: + while self.runs_count < self.max_runs: await self.wrapped_func(*args, **kwargs) - if self.max_runs > 0: - self.runs += 1 + if self.has_limited_runs: + self.runs_count += 1 await asyncio.sleep(self.duration) + @property + def has_limited_runs(self) -> bool: + return self.max_runs != INFINITE_RUNS + def run(self, *args: typing.Any, **kwargs: typing.Any): self._task = asyncio.create_task(self.start(*args, **kwargs)) @@ -34,7 +41,7 @@ def cancel(self): self._task.cancel() -def task(duration: timedelta, max_runs=-1): +def task(duration: timedelta, max_runs=INFINITE_RUNS): def wrap(function): return Task(function, duration.total_seconds(), max_runs) From 85d1d2933bbe8e9c2457a42b293731fabfdd6d4d Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sun, 12 Feb 2023 20:04:42 +0100 Subject: [PATCH 182/335] :recycle: Remove magic indexing for mime type guesser --- EpikCord/exceptions.py | 7 +++++++ EpikCord/file.py | 15 ++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/EpikCord/exceptions.py b/EpikCord/exceptions.py index cec6523b..4049e43e 100644 --- a/EpikCord/exceptions.py +++ b/EpikCord/exceptions.py @@ -94,3 +94,10 @@ class GatewayRateLimited(ClosedWebSocketConnection): class ShardingRequired(ClosedWebSocketConnection): ... + + +class UnknownMimeType(EpikCordException): + + def __init__(self, filename): + self.filename = filename + self.message = f"Cannot resolve mime type for file `{filename}`." diff --git a/EpikCord/file.py b/EpikCord/file.py index d6190623..35492117 100644 --- a/EpikCord/file.py +++ b/EpikCord/file.py @@ -2,6 +2,8 @@ import mimetypes from typing import Optional +from .exceptions import UnknownMimeType + class File: def __init__( @@ -28,11 +30,18 @@ def __init__( The description of the file. """ self.contents: io.IOBase = contents - self.mime_type: Optional[str] = ( - mime_type or mimetypes.guess_type(filename)[0] - ) + self.mime_type = mime_type or _guess_mime_type(filename) + + if self.mime_type is None: + raise UnknownMimeType(filename) + if spoiler: self.filename = f"SPOILER_{filename}" else: self.filename = filename self.description: Optional[str] = description + + +def _guess_mime_type(filename: str) -> Optional[str]: + mime_type, _encoding = mimetypes.guess_type(filename) + return mime_type From fa31b38ef2fae056efabcb0263312ba42b1cd376 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sun, 12 Feb 2023 20:09:23 +0100 Subject: [PATCH 183/335] :recycle: Simplify clean url --- EpikCord/utils/loose.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/EpikCord/utils/loose.py b/EpikCord/utils/loose.py index 32d62142..4a58a45b 100644 --- a/EpikCord/utils/loose.py +++ b/EpikCord/utils/loose.py @@ -43,15 +43,8 @@ def json_serialize(data, *args, **kwargs): def clean_url(url: str, version: int) -> str: - if url.startswith("/"): - url = f"https://discord.com/api/v{version}{url}" - else: - url = f"https://discord.com/api/v{version}/{url}" - - if url.endswith("/"): - url = url[:-1] - - return url + clean_uri: str = url.strip('/') + return f"https://discord.com/api/v{version}/{clean_uri}" async def extract_content(response: aiohttp.ClientResponse) -> Dict[str, Any]: From 7baac3de35ab065741d5246c49bfa15855a5aed7 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sun, 12 Feb 2023 20:18:45 +0100 Subject: [PATCH 184/335] :recycle: Rooting out ambiguity of gateway limitations and time values --- EpikCord/client/rate_limit_tools.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/EpikCord/client/rate_limit_tools.py b/EpikCord/client/rate_limit_tools.py index 0a8064a9..863a972d 100644 --- a/EpikCord/client/rate_limit_tools.py +++ b/EpikCord/client/rate_limit_tools.py @@ -3,23 +3,26 @@ import asyncio from datetime import timedelta from logging import getLogger -from typing import Any, Dict, Optional, Union +from typing import Any, Dict, Optional, Union, Final from ..ext import tasks from ..utils import clear_none_values logger = getLogger("EpikCord.http") +_ONE_MINUTE: Final[int] = 60 +MAXIMUM_GATEWAY_EVENT: Final[int] = 120 + class GatewayRateLimiter: def __init__(self): self.event = asyncio.Event() self.event.set() - self.remaining = 120 - self.limit = 120 + self.remaining = MAXIMUM_GATEWAY_EVENT + self.limit = MAXIMUM_GATEWAY_EVENT - @tasks.task(duration=timedelta(seconds=60)) + @tasks.task(duration=timedelta(seconds=_ONE_MINUTE)) async def reset(self): self.remaining = self.limit self.event.set() From 1f37eba8d2c0e93e5e03a9ed309890cfa4e6ad6c Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Sun, 12 Feb 2023 20:28:42 +0100 Subject: [PATCH 185/335] :recycle: Improve close_dispatcher readability --- EpikCord/client/ws_close_handler.py | 105 ++++++++++++++++------------ 1 file changed, 60 insertions(+), 45 deletions(-) diff --git a/EpikCord/client/ws_close_handler.py b/EpikCord/client/ws_close_handler.py index e2e6d97d..3df666a1 100644 --- a/EpikCord/client/ws_close_handler.py +++ b/EpikCord/client/ws_close_handler.py @@ -1,4 +1,5 @@ import logging +from functools import partial from typing import Dict, Optional, Type from ..exceptions import ( @@ -36,50 +37,64 @@ def __init__( self.need_report = need_report -ignore = CloseHandler() +_ignore = CloseHandler() +CloseHandlerLogReported = partial(CloseHandlerLog, need_report=True) + +_close_disallowed_intents = CloseHandlerRaise( + DisallowedIntents, + "You cannot use privileged intents with this token, go to the developer" + " portal and allow the privileged intents needed. ", +) +_close_authentication_failed = CloseHandlerRaise( + InvalidToken, "The token you provided is invalid." +) +_close_rate_limited = CloseHandlerRaise( + GatewayRateLimited, + "You've been rate limited. Try again in a few minutes.", +) +_close_sharding_required = CloseHandlerRaise( + ShardingRequired, "You need to shard the bot." +) +_close_invalid_api_version = CloseHandlerRaise( + DeprecationWarning, + "The gateway you're connecting to is deprecated and does not work," + " update EpikCord.", +) +_close_invalid_intents = CloseHandlerRaise( + InvalidIntents, "The intents you provided are invalid." +) +_close_unknown_opcode = CloseHandlerLogReported( + "EpikCord.py sent an invalid OPCODE to the Gateway." +) +_close_decode_error = CloseHandlerLogReported( + "EpikCord.py sent an invalid payload to the Gateway." +) +_close_not_authenticated = CloseHandlerLog( + "EpikCord.py has sent a payload prior to identifying.", + resumable=False, + need_report=True, +) +_close_already_authenticated = CloseHandlerLogReported( + "EpikCord.py tried to authenticate again." +) +_close_invalid_sequence = CloseHandlerLogReported( + "EpikCord.py sent an invalid sequence number." +) +_close_session_timed_out = CloseHandlerLog("Session timed out.") + close_dispatcher: Dict[GatewayCloseCode, CloseHandler] = { - GatewayCloseCode.ABNORMAL_CLOSURE: ignore, - GatewayCloseCode.UNKNOWN_ERROR: ignore, - GatewayCloseCode.DISALLOWED_INTENTS: CloseHandlerRaise( - DisallowedIntents, - "You cannot use privileged intents with this token, go to " - "the developer portal and allow the privileged intents " - "needed. ", - ), - GatewayCloseCode.AUTHENTICATION_FAILED: CloseHandlerRaise( - InvalidToken, "The token you provided is invalid." - ), - GatewayCloseCode.RATE_LIMITED: CloseHandlerRaise( - GatewayRateLimited, - "You've been rate limited. Try again in a few minutes.", - ), - GatewayCloseCode.SHARDING_REQUIRED: CloseHandlerRaise( - ShardingRequired, "You need to shard the bot." - ), - GatewayCloseCode.INVALID_API_VERSION: CloseHandlerRaise( - DeprecationWarning, - "The gateway you're connecting to is deprecated and does not " - "work, update EpikCord.", - ), - GatewayCloseCode.INVALID_INTENTS: CloseHandlerRaise( - InvalidIntents, "The intents you provided are invalid." - ), - GatewayCloseCode.UNKNOWN_OPCODE: CloseHandlerLog( - "EpikCord.py sent an invalid OPCODE to the Gateway. ", need_report=True - ), - GatewayCloseCode.DECODE_ERROR: CloseHandlerLog( - "EpikCord.py sent an invalid payload to the Gateway.", need_report=True - ), - GatewayCloseCode.NOT_AUTHENTICATED: CloseHandlerLog( - "EpikCord.py has sent a payload prior to identifying.", - resumable=False, - need_report=True, - ), - GatewayCloseCode.ALREADY_AUTHENTICATED: CloseHandlerLog( - "EpikCord.py tried to authenticate again.", need_report=True - ), - GatewayCloseCode.INVALID_SEQUENCE: CloseHandlerLog( - "EpikCord.py sent an invalid sequence number.", need_report=True - ), - GatewayCloseCode.SESSION_TIMED_OUT: CloseHandlerLog("Session timed out."), + GatewayCloseCode.ABNORMAL_CLOSURE: _ignore, + GatewayCloseCode.UNKNOWN_ERROR: _ignore, + GatewayCloseCode.DISALLOWED_INTENTS: _close_disallowed_intents, + GatewayCloseCode.AUTHENTICATION_FAILED: _close_authentication_failed, + GatewayCloseCode.RATE_LIMITED: _close_rate_limited, + GatewayCloseCode.SHARDING_REQUIRED: _close_sharding_required, + GatewayCloseCode.INVALID_API_VERSION: _close_invalid_api_version, + GatewayCloseCode.INVALID_INTENTS: _close_invalid_intents, + GatewayCloseCode.UNKNOWN_OPCODE: _close_unknown_opcode, + GatewayCloseCode.DECODE_ERROR: _close_decode_error, + GatewayCloseCode.NOT_AUTHENTICATED: _close_not_authenticated, + GatewayCloseCode.ALREADY_AUTHENTICATED: _close_already_authenticated, + GatewayCloseCode.INVALID_SEQUENCE: _close_invalid_sequence, + GatewayCloseCode.SESSION_TIMED_OUT: _close_session_timed_out, } From 2bf40ec8ad7bfaf410a16be9131cca793be5c8db Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 12 Feb 2023 19:30:18 +0000 Subject: [PATCH 186/335] :art: Format code automatically --- EpikCord/client/rate_limit_tools.py | 2 +- EpikCord/exceptions.py | 1 - EpikCord/ext/tasks.py | 1 - EpikCord/flags.py | 2 +- EpikCord/utils/loose.py | 2 +- 5 files changed, 3 insertions(+), 5 deletions(-) diff --git a/EpikCord/client/rate_limit_tools.py b/EpikCord/client/rate_limit_tools.py index 863a972d..e9f6fb5b 100644 --- a/EpikCord/client/rate_limit_tools.py +++ b/EpikCord/client/rate_limit_tools.py @@ -3,7 +3,7 @@ import asyncio from datetime import timedelta from logging import getLogger -from typing import Any, Dict, Optional, Union, Final +from typing import Any, Dict, Final, Optional, Union from ..ext import tasks from ..utils import clear_none_values diff --git a/EpikCord/exceptions.py b/EpikCord/exceptions.py index 4049e43e..3d7642b7 100644 --- a/EpikCord/exceptions.py +++ b/EpikCord/exceptions.py @@ -97,7 +97,6 @@ class ShardingRequired(ClosedWebSocketConnection): class UnknownMimeType(EpikCordException): - def __init__(self, filename): self.filename = filename self.message = f"Cannot resolve mime type for file `{filename}`." diff --git a/EpikCord/ext/tasks.py b/EpikCord/ext/tasks.py index a7820b91..e02f6552 100644 --- a/EpikCord/ext/tasks.py +++ b/EpikCord/ext/tasks.py @@ -3,7 +3,6 @@ from datetime import timedelta from typing import Callable, Coroutine, Final - INFINITE_RUNS: Final[int] = -1 diff --git a/EpikCord/flags.py b/EpikCord/flags.py index 6f401ae9..b024e8c5 100644 --- a/EpikCord/flags.py +++ b/EpikCord/flags.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Dict, List, Final +from typing import Any, Dict, Final, List ALL_VALUE_DISABLED: Final[int] = 0 diff --git a/EpikCord/utils/loose.py b/EpikCord/utils/loose.py index 4a58a45b..530bdab2 100644 --- a/EpikCord/utils/loose.py +++ b/EpikCord/utils/loose.py @@ -43,7 +43,7 @@ def json_serialize(data, *args, **kwargs): def clean_url(url: str, version: int) -> str: - clean_uri: str = url.strip('/') + clean_uri: str = url.strip("/") return f"https://discord.com/api/v{version}/{clean_uri}" From 64e46fa08611c0e018474a06ae614797f616f1cb Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sun, 12 Feb 2023 19:43:58 +0000 Subject: [PATCH 187/335] Resolve issues with RECONNECT --- EpikCord/client/http.py | 1 + EpikCord/client/websocket.py | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 2dd9b413..27f1c0ab 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -200,6 +200,7 @@ async def set_bucket( url = response.url method = response.method bucket_key: str = f"{method}:{url}" + bucket: Union[Bucket, TopLevelBucket] if major_parameters: bucket = TopLevelBucket( diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 91331500..eeb04ee8 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -209,8 +209,6 @@ async def reconnect(self, _): if self.client.ws: await self.client.ws.close() await self.client.connect(resume=True) - if self.client.session_id and self.client.sequence: - await self.resume() async def invalid_session(self, resumable: bool): if self.client.ws: @@ -292,7 +290,7 @@ async def receive(self, *args, **kwargs): async def close(self, *, code: int = 4000, message: bytes = b"") -> bool: logger.debug( - "Closing websocket with code %s and message %s", code, message + "Closing websocket with code %s", code ) return await super().close(code=code, message=message) @@ -350,6 +348,9 @@ async def connect(self, *, resume: bool = False): self.ws = await self.http.ws_connect(url) asyncio.create_task(self.rate_limiter.reset.start()) + if resume: + await self.event_handler.resume() + async for message in self.ws: logger.debug("Received message: %s", message.json()) await self.event_handler.handle(message) # type: ignore From 7782b1934a03a7d492b0f51eff2e5ad40a1083fd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 12 Feb 2023 19:44:46 +0000 Subject: [PATCH 188/335] :art: Format code automatically --- EpikCord/client/websocket.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index eeb04ee8..a25f65a2 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -289,9 +289,7 @@ async def receive(self, *args, **kwargs): ) async def close(self, *, code: int = 4000, message: bytes = b"") -> bool: - logger.debug( - "Closing websocket with code %s", code - ) + logger.debug("Closing websocket with code %s", code) return await super().close(code=code, message=message) From 129bee95b59f19e92562f24f126df9bc799f6b44 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sun, 12 Feb 2023 20:18:11 +0000 Subject: [PATCH 189/335] Current state --- EpikCord/client/websocket.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index eeb04ee8..72d870f5 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -381,4 +381,4 @@ async def handle_close(self): logger.critical(ch_ins.message + report_msg) if ch_ins.resumable: - await self.event_handler.resume() + await self.connect(resume=True) From 452c78301b19d36f3e9e89e8e858ae6170dfb72d Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Mon, 13 Feb 2023 10:35:33 +0000 Subject: [PATCH 190/335] Fix event handling --- EpikCord/client/websocket.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index cacaed2c..28d8ca9f 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -70,21 +70,22 @@ def __init__(self, client: WebSocketClient): OpCode.HEARTBEAT_ACK: self.heartbeat_ack, } - def event(self): + def event(self, func: AsyncFunction): """Register an event handler. This is a decorator.""" - def decorator(func: AsyncFunction): - name = func.__name__.lower().replace("on_", "") - self.events[name].append(func) - return func - - return decorator + name = func.__name__.lower().replace("on_", "") + self.events[name].append(func) + logger.info("Registered event %s", name) + return func async def dispatch(self, event_name: str, *args, **kwargs): """Dispatch an event to all event handlers.""" for event in self.events[event_name]: asyncio.create_task(event(*args, **kwargs)) + logger.debug("Dispatched event %s", event_name) + + @staticmethod async def heartbeat_ack(_: Any): """Handle the heartbeat ack event. OpCode 11.""" @@ -248,11 +249,15 @@ async def handle(self, message: DiscordWSMessage): if event["op"] != OpCode.DISPATCH: await self.opcode_mapping[event["op"]](event["d"]) else: + event_name = event["t"].lower() + event_data = event["d"] + + if event_handler := getattr(self, f"_{event_name}", None): + asyncio.create_task(event_handler(event_data)) + # TODO: Once we have completed the HTTP objects, # we can then start to transform them before they reach # the end user. - await self.dispatch(event["t"].lower(), event["d"]) - class DiscordWSMessage: def __init__(self, *, data, msg_type, extra): @@ -320,6 +325,8 @@ def __init__( self.heartbeat_interval: Optional[float] = None self._heartbeats: List[int] = [] + self.event = self.event_handler.event + @property def latency(self) -> Optional[float]: if not self._heartbeats: From 3f12758352bf1dd6933578a44fe0297496c9feba Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 13 Feb 2023 10:36:34 +0000 Subject: [PATCH 191/335] :art: Format code automatically --- EpikCord/client/websocket.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 28d8ca9f..9adcafc2 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -85,7 +85,6 @@ async def dispatch(self, event_name: str, *args, **kwargs): logger.debug("Dispatched event %s", event_name) - @staticmethod async def heartbeat_ack(_: Any): """Handle the heartbeat ack event. OpCode 11.""" @@ -259,6 +258,7 @@ async def handle(self, message: DiscordWSMessage): # we can then start to transform them before they reach # the end user. + class DiscordWSMessage: def __init__(self, *, data, msg_type, extra): self.data = data From 2401a46a1f0c06d9ca0a0ce3f364d221de1c673f Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Tue, 14 Feb 2023 12:46:42 +0000 Subject: [PATCH 192/335] Add Get Gateway Bot endpoint --- EpikCord/client/http.py | 93 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 84 insertions(+), 9 deletions(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 27f1c0ab..62f51101 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type, Union import aiohttp +from discord_typings import GetGatewayBotData, SessionStartLimitData from .. import __version__ from ..exceptions import ( @@ -46,9 +47,78 @@ class APIVersion(IntEnum): NINE = 9 TEN = 10 +class SessionStartLimit: + """Represents the session start limit data. + + Attributes + ---------- + total: int + The total number of sessions that can be started in a 24 hour period. + remaining: int + The remaining number of sessions that can be started in this 24 hour period. + reset_after: int + The number of seconds until the remaining number of sessions is reset. + max_concurrency: int + The maximum number of IDENTIFY requests that can be sent in 5 seconds. + """ + + def __init__(self, data: SessionStartLimitData): + """ + Parameters + ---------- + data: SessionStartLimitData + The data returned by the Discord API. + """ + + self.total: int = data["total"] + self.remaining: int = data["remaining"] + self.reset_after: int = data["reset_after"] + self.max_concurrency: int = data["max_concurrency"] + +class GatewayBotData: + """Represents the data returned by the Get Gateway Bot endpoint. + + Attributes + ---------- + url: str + The URL to use for connecting to the gateway. + shards: int + The recommended amount of shards to use. + session_start_limit: SessionStartLimit + The session start limit data. + """ + def __init__(self, data: GetGatewayBotData): + """ + Parameters + ---------- + data: GetGatewayBotData + The data returned by the Discord API. + """ + + self.url: str = data["url"] + self.shards: int = data["shards"] + self.session_start_limit: SessionStartLimit = SessionStartLimit(data["session_start_limit"]) + class HTTPClient: - """The HTTPClient used to make requests to the Discord API.""" + """The HTTPClient used to make requests to the Discord API. + + Attributes + ---------- + token: str + The token of the bot. Used as authorization. + Should be kept private at all times. + version: int + The version of the Discord API to use. + session: aiohttp.ClientSession + The underlying session used to make HTTP requests. + global_event: asyncio.Event + The Event used to implement global ratelimits. + buckets: Dict[str, Union[Bucket, TopLevelBucket]] + A mapping of bucket hashes to Bucket instances. + error_mapping: Dict[HTTPCodes, Type[HTTPException]] + A mapping of HTTP status codes to their respective exceptions. + """ error_mapping: Dict[HTTPCodes, Type[HTTPException]] = { HTTPCodes.BAD_REQUEST: BadRequest, @@ -68,14 +138,6 @@ def __init__( Should be kept private at all times. version: int The version of the Discord API to use. Defaults to 10. - - Attributes - ---------- - token: str - The token of the bot. Used as authorization. - Should be kept private at all times. - version: int - The version of the Discord API to use. """ self.token: TokenStore = token self.version: APIVersion = version @@ -305,3 +367,16 @@ async def get_gateway(self) -> str: response = await self.request(Route("GET", "/gateway")) data = await response.json() return data["url"] + + async def get_gateway_bot(self) -> GatewayBotData: + """Gets information and recommendations for connecting to the Gateway. + + Returns + ------- + GatewayBotData + The data returned from the request. + """ + response = await self.request(Route("GET", "/gateway/bot")) + data = await response.json() + return GatewayBotData(data) + From 5cd113b5d1d300128b01a139a8dbdd5279aa3add Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 14 Feb 2023 12:48:30 +0000 Subject: [PATCH 193/335] :art: Format code automatically --- EpikCord/client/http.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 62f51101..8676f448 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -47,6 +47,7 @@ class APIVersion(IntEnum): NINE = 9 TEN = 10 + class SessionStartLimit: """Represents the session start limit data. @@ -75,6 +76,7 @@ def __init__(self, data: SessionStartLimitData): self.reset_after: int = data["reset_after"] self.max_concurrency: int = data["max_concurrency"] + class GatewayBotData: """Represents the data returned by the Get Gateway Bot endpoint. @@ -87,6 +89,7 @@ class GatewayBotData: session_start_limit: SessionStartLimit The session start limit data. """ + def __init__(self, data: GetGatewayBotData): """ Parameters @@ -97,12 +100,14 @@ def __init__(self, data: GetGatewayBotData): self.url: str = data["url"] self.shards: int = data["shards"] - self.session_start_limit: SessionStartLimit = SessionStartLimit(data["session_start_limit"]) + self.session_start_limit: SessionStartLimit = SessionStartLimit( + data["session_start_limit"] + ) class HTTPClient: """The HTTPClient used to make requests to the Discord API. - + Attributes ---------- token: str @@ -370,7 +375,7 @@ async def get_gateway(self) -> str: async def get_gateway_bot(self) -> GatewayBotData: """Gets information and recommendations for connecting to the Gateway. - + Returns ------- GatewayBotData @@ -379,4 +384,3 @@ async def get_gateway_bot(self) -> GatewayBotData: response = await self.request(Route("GET", "/gateway/bot")) data = await response.json() return GatewayBotData(data) - From 44392d36e0c9a486c8ac228727e4a3ab4f6a9981 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Tue, 14 Feb 2023 13:11:26 +0000 Subject: [PATCH 194/335] Add Application object" --- EpikCord/application.py | 48 ++++++++++++++++++++++++++++++++++++++ EpikCord/flags.py | 11 +++++++++ EpikCord/utils/__init__.py | 2 ++ EpikCord/utils/enums.py | 6 +++++ 4 files changed, 67 insertions(+) create mode 100644 EpikCord/application.py diff --git a/EpikCord/application.py b/EpikCord/application.py new file mode 100644 index 00000000..64d09b16 --- /dev/null +++ b/EpikCord/application.py @@ -0,0 +1,48 @@ +from typing import List, Optional +from .utils import TeamMemberMembershipState +from .flags import Permissions, ApplicationFlags +from discord_typings import ApplicationData, InstallParams as InstallParamsData, TeamData, TeamMemberData + +class InstallParams: + def __init__(self, data: InstallParamsData): + self.scopes: List[str] = data["scopes"] + self.permissions: Permissions = Permissions(int(data["permissions"])) + +class TeamMember: + def __init__(self, data: TeamMemberData): + self.membership_state: TeamMemberMembershipState = TeamMemberMembershipState(data["membership_state"]) + self.permissions: List[str] = data["permissions"] + self.team_id: int = int(data["team_id"]) + self.user = data["user"] # TODO: User Object + +class Team: + def __init__(self, data: TeamData): + self.icon: Optional[str] = data["icon"] + self.id: int = int(data["id"]) + self.members: List[TeamMember] = [TeamMember(member) for member in data["members"]] + self.name: str = data["name"] + self.owner_user_id: int = int(data["owner_user_id"]) + +class Application: + def __init__(self, data: ApplicationData): + self.id: int = int(data["id"]) + self.name: str = data["name"] + self.icon: Optional[str] = data.get("icon") + self.description: str = data["description"] + self.rpc_origins: Optional[List[str]] = data.get("rpc_origins") + self.bot_public: bool = data["bot_public"] + self.bot_require_code_grant: bool = data["bot_require_code_grant"] + self.terms_of_service_url: Optional[str] = data.get("terms_of_service_url") + self.privacy_policy_url: Optional[str] = data.get("privacy_policy_url") + self.owner = data.get("owner") # TODO: User Object + self.verify_key: str = data["verify_key"] + self.team: Optional[Team] = Team(data["team"]) if data["team"] else None + self.guild_id: Optional[int] = int(data["guild_id"]) if "guild_id" in data else None + self.primary_sku_id: Optional[int] = int(data["primary_sku_id"]) if "primary_sku_id" in data else None + self.slug: Optional[str] = data["slug"] if "slug" in data else None + self.cover_image: Optional[str] = data["cover_image"] if "cover_image" in data else None + self.flags: Optional[ApplicationFlags] = ApplicationFlags(data["flags"]) if "flags" in data else None # TODO: Values + self.tags: Optional[List[str]] = data["tags"] if "tags" in data else None + self.install_params: Optional[InstallParams] = InstallParams(data["install_params"]) if "install_params" in data else None + self.custom_install_url: Optional[str] = data["custom_install_url"] if "custom_install_url" in data else None + self.role_connections_verification_url: Optional[str] = data["role_connections_verification_url"] if "role_connections_verification_url" in data else None diff --git a/EpikCord/flags.py b/EpikCord/flags.py index b024e8c5..aa333a09 100644 --- a/EpikCord/flags.py +++ b/EpikCord/flags.py @@ -131,3 +131,14 @@ class Permissions(Flag): class ChannelFlags(Flag): PINNED = 1 << 1 REQUIRE_TAG = 1 << 4 + +class ApplicationFlags(Flag): + GATEWAY_PRESENCE = 1 << 12 + GATEWAY_PRESENCE_LIMITED = 1 << 13 + GATEWAY_GUILD_MEMBERS = 1 << 14 + GATEWAY_GUILD_MEMBERS_LIMITED = 1 << 15 + VERIFICATION_PENDING_GUILD_LIMIT = 1 << 16 + EMBEDDED = 1 << 17 + GATEWAY_MESSAGE_CONTENT = 1 << 18 + GATEWAY_MESSAGE_CONTENT_LIMITED = 1 << 19 + APPLICATION_COMMAND_BADGE = 1 << 23 diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py index cb2d5c4f..acfa584c 100644 --- a/EpikCord/utils/__init__.py +++ b/EpikCord/utils/__init__.py @@ -5,6 +5,7 @@ OpCode, VoiceCloseCode, VoiceOpCode, + TeamMemberMembershipState, ) from .loose import ( add_file, @@ -35,6 +36,7 @@ "SendingAttachmentData", "VoiceCloseCode", "VoiceOpCode", + "TeamMemberMembershipState", "add_file", "cancel_tasks", "clean_url", diff --git a/EpikCord/utils/enums.py b/EpikCord/utils/enums.py index d12b4d4d..60c97360 100644 --- a/EpikCord/utils/enums.py +++ b/EpikCord/utils/enums.py @@ -344,3 +344,9 @@ def _missing_(cls, value: object) -> JSONErrorCodes: logger = getLogger("EpikCord.exceptions") logger.warning(f"Unknown JSON error code: {value}") return cls.GENERAL_ERROR + +class TeamMemberMembershipState(IntEnum): + """The state of a team member.""" + + INVITED = 1 + ACCEPTED = 2 \ No newline at end of file From 330bc87106e88e3e7d86c476f7a9bb6d4032d46d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 14 Feb 2023 13:12:33 +0000 Subject: [PATCH 195/335] :art: Format code automatically --- EpikCord/application.py | 64 +++++++++++++++++++++++++++++--------- EpikCord/flags.py | 1 + EpikCord/utils/__init__.py | 2 +- EpikCord/utils/enums.py | 3 +- 4 files changed, 53 insertions(+), 17 deletions(-) diff --git a/EpikCord/application.py b/EpikCord/application.py index 64d09b16..b19360e0 100644 --- a/EpikCord/application.py +++ b/EpikCord/application.py @@ -1,28 +1,40 @@ from typing import List, Optional + +from discord_typings import ApplicationData +from discord_typings import InstallParams as InstallParamsData +from discord_typings import TeamData, TeamMemberData + +from .flags import ApplicationFlags, Permissions from .utils import TeamMemberMembershipState -from .flags import Permissions, ApplicationFlags -from discord_typings import ApplicationData, InstallParams as InstallParamsData, TeamData, TeamMemberData + class InstallParams: def __init__(self, data: InstallParamsData): self.scopes: List[str] = data["scopes"] self.permissions: Permissions = Permissions(int(data["permissions"])) + class TeamMember: def __init__(self, data: TeamMemberData): - self.membership_state: TeamMemberMembershipState = TeamMemberMembershipState(data["membership_state"]) + self.membership_state: TeamMemberMembershipState = ( + TeamMemberMembershipState(data["membership_state"]) + ) self.permissions: List[str] = data["permissions"] self.team_id: int = int(data["team_id"]) - self.user = data["user"] # TODO: User Object + self.user = data["user"] # TODO: User Object + class Team: def __init__(self, data: TeamData): self.icon: Optional[str] = data["icon"] self.id: int = int(data["id"]) - self.members: List[TeamMember] = [TeamMember(member) for member in data["members"]] + self.members: List[TeamMember] = [ + TeamMember(member) for member in data["members"] + ] self.name: str = data["name"] self.owner_user_id: int = int(data["owner_user_id"]) + class Application: def __init__(self, data: ApplicationData): self.id: int = int(data["id"]) @@ -32,17 +44,39 @@ def __init__(self, data: ApplicationData): self.rpc_origins: Optional[List[str]] = data.get("rpc_origins") self.bot_public: bool = data["bot_public"] self.bot_require_code_grant: bool = data["bot_require_code_grant"] - self.terms_of_service_url: Optional[str] = data.get("terms_of_service_url") + self.terms_of_service_url: Optional[str] = data.get( + "terms_of_service_url" + ) self.privacy_policy_url: Optional[str] = data.get("privacy_policy_url") - self.owner = data.get("owner") # TODO: User Object + self.owner = data.get("owner") # TODO: User Object self.verify_key: str = data["verify_key"] self.team: Optional[Team] = Team(data["team"]) if data["team"] else None - self.guild_id: Optional[int] = int(data["guild_id"]) if "guild_id" in data else None - self.primary_sku_id: Optional[int] = int(data["primary_sku_id"]) if "primary_sku_id" in data else None + self.guild_id: Optional[int] = ( + int(data["guild_id"]) if "guild_id" in data else None + ) + self.primary_sku_id: Optional[int] = ( + int(data["primary_sku_id"]) if "primary_sku_id" in data else None + ) self.slug: Optional[str] = data["slug"] if "slug" in data else None - self.cover_image: Optional[str] = data["cover_image"] if "cover_image" in data else None - self.flags: Optional[ApplicationFlags] = ApplicationFlags(data["flags"]) if "flags" in data else None # TODO: Values - self.tags: Optional[List[str]] = data["tags"] if "tags" in data else None - self.install_params: Optional[InstallParams] = InstallParams(data["install_params"]) if "install_params" in data else None - self.custom_install_url: Optional[str] = data["custom_install_url"] if "custom_install_url" in data else None - self.role_connections_verification_url: Optional[str] = data["role_connections_verification_url"] if "role_connections_verification_url" in data else None + self.cover_image: Optional[str] = ( + data["cover_image"] if "cover_image" in data else None + ) + self.flags: Optional[ApplicationFlags] = ( + ApplicationFlags(data["flags"]) if "flags" in data else None + ) # TODO: Values + self.tags: Optional[List[str]] = ( + data["tags"] if "tags" in data else None + ) + self.install_params: Optional[InstallParams] = ( + InstallParams(data["install_params"]) + if "install_params" in data + else None + ) + self.custom_install_url: Optional[str] = ( + data["custom_install_url"] if "custom_install_url" in data else None + ) + self.role_connections_verification_url: Optional[str] = ( + data["role_connections_verification_url"] + if "role_connections_verification_url" in data + else None + ) diff --git a/EpikCord/flags.py b/EpikCord/flags.py index aa333a09..1fe8e713 100644 --- a/EpikCord/flags.py +++ b/EpikCord/flags.py @@ -132,6 +132,7 @@ class ChannelFlags(Flag): PINNED = 1 << 1 REQUIRE_TAG = 1 << 4 + class ApplicationFlags(Flag): GATEWAY_PRESENCE = 1 << 12 GATEWAY_PRESENCE_LIMITED = 1 << 13 diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py index acfa584c..723a60f7 100644 --- a/EpikCord/utils/__init__.py +++ b/EpikCord/utils/__init__.py @@ -3,9 +3,9 @@ HTTPCodes, JSONErrorCodes, OpCode, + TeamMemberMembershipState, VoiceCloseCode, VoiceOpCode, - TeamMemberMembershipState, ) from .loose import ( add_file, diff --git a/EpikCord/utils/enums.py b/EpikCord/utils/enums.py index 60c97360..a38c3571 100644 --- a/EpikCord/utils/enums.py +++ b/EpikCord/utils/enums.py @@ -345,8 +345,9 @@ def _missing_(cls, value: object) -> JSONErrorCodes: logger.warning(f"Unknown JSON error code: {value}") return cls.GENERAL_ERROR + class TeamMemberMembershipState(IntEnum): """The state of a team member.""" INVITED = 1 - ACCEPTED = 2 \ No newline at end of file + ACCEPTED = 2 From a6befccc00e0f702f68daf80f487044957ebda1c Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Tue, 14 Feb 2023 13:28:21 +0000 Subject: [PATCH 196/335] Add user object --- EpikCord/flags.py | 18 +++++++++++++++- EpikCord/user.py | 25 ++++++++++++++++++++++ EpikCord/utils/__init__.py | 4 ++++ EpikCord/utils/enums.py | 44 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 EpikCord/user.py diff --git a/EpikCord/flags.py b/EpikCord/flags.py index aa333a09..d4960b16 100644 --- a/EpikCord/flags.py +++ b/EpikCord/flags.py @@ -127,7 +127,6 @@ class Permissions(Flag): START_EMBEDDED_ACTIVITIES = 1 << 39 MODERATOR_MEMBERS = 1 << 40 - class ChannelFlags(Flag): PINNED = 1 << 1 REQUIRE_TAG = 1 << 4 @@ -142,3 +141,20 @@ class ApplicationFlags(Flag): GATEWAY_MESSAGE_CONTENT = 1 << 18 GATEWAY_MESSAGE_CONTENT_LIMITED = 1 << 19 APPLICATION_COMMAND_BADGE = 1 << 23 + +class UserFlags(Flag): + STAFF = 1 << 0 + PARTNER = 1 << 1 + HYPESQUAD = 1 << 2 + BUG_HUNTER_LEVEL_ONE = 1 << 3 + HYPESQUAD_ONLINE_HOUSE_1 = 1 << 6 + HYPESQUAD_ONLINE_HOUSE_2 = 1 << 7 + HYPESQUAD_ONLINE_HOUSE_3 = 1 << 8 + PREMIUM_EARLY_SUPPORTER = 1 << 9 + TEAM_PSEUDO_USER = 1 << 10 + BUG_HUNTER_LEVEL_TWO = 1 << 14 + VERIFIED_BOT = 1 << 16 + VERIFIED_BOT_DEVELOPER = 1 << 17 + CERTIFIED_MODERATOR = 1 << 18 + BOT_HTTP_INTERACTIONS = 1 << 19 + ACTIVE_DEVELOPER = 1 << 22 \ No newline at end of file diff --git a/EpikCord/user.py b/EpikCord/user.py new file mode 100644 index 00000000..b90d94f2 --- /dev/null +++ b/EpikCord/user.py @@ -0,0 +1,25 @@ +from typing import Optional + +from .utils import Locale, PremiumType +from .flags import UserFlags + +from discord_typings import UserData + + +class User: + def __init__(self, data: UserData): + self.id: int = int(data["id"]) + self.username: str = data["username"] + self.discriminator: str = data["discriminator"] + self.avatar: Optional[str] = data.get("avatar") + self.bot: Optional[bool] = data.get("bot") + self.system: Optional[bool] = data.get("system") + self.mfa_enabled: Optional[bool] = data.get("mfa_enabled") + self.banner: Optional[str] = data.get("banner") + self.accent_color: Optional[int] = int(data["accent_color"]) if "accent_color" in data and data["accent_color"] else None + self.locale: Optional[Locale] = Locale(data["locale"]) if "locale" in data else None + self.verified: Optional[bool] = data.get("verified") + self.email: Optional[str] = data.get("email") + self.flags: Optional[UserFlags] = UserFlags(data["flags"]) if "flags" in data else None + self.premium_type: Optional[PremiumType] = PremiumType(data["premium_type"]) if "premium_type" in data else None + self.public_flags: Optional[UserFlags] = UserFlags(data["public_flags"]) if "public_flags" in data else None \ No newline at end of file diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py index acfa584c..fe85a431 100644 --- a/EpikCord/utils/__init__.py +++ b/EpikCord/utils/__init__.py @@ -6,6 +6,8 @@ VoiceCloseCode, VoiceOpCode, TeamMemberMembershipState, + Locale, + PremiumType ) from .loose import ( add_file, @@ -37,6 +39,8 @@ "VoiceCloseCode", "VoiceOpCode", "TeamMemberMembershipState", + "Locale", + "PremiumType", "add_file", "cancel_tasks", "clean_url", diff --git a/EpikCord/utils/enums.py b/EpikCord/utils/enums.py index 60c97360..4a710370 100644 --- a/EpikCord/utils/enums.py +++ b/EpikCord/utils/enums.py @@ -1,6 +1,6 @@ from __future__ import annotations -from enum import IntEnum +from enum import Enum, IntEnum from logging import getLogger from typing import Final @@ -349,4 +349,44 @@ class TeamMemberMembershipState(IntEnum): """The state of a team member.""" INVITED = 1 - ACCEPTED = 2 \ No newline at end of file + ACCEPTED = 2 + + +class Locale(Enum): + INDONESIAN = "id" + DANISH = "da" + GERMAN = "de" + ENGLISH_UK = "en-GB" + ENGLISH_US = "en-US" + SPANISH = "es-ES" + FRENCH = "fr" + CROATIAN = "hr" + ITALIAN = "it" + LITHUANIAN = "lt" + HUNGARIAN = "hu" + DUTCH = "nl" + NORWEGIAN = "no" + POLISH = "pl" + PORTUGUESE_BRAZILIAN = "pt-BR" + ROMANIAN_ROMANIA = "ro" + FINNISH = "fi" + SWEDISH = "sv-SE" + VIETNAMESE = "vi" + TURKISH = "tr" + CZECH = "cs" + GREEK = "el" + BULGARIAN = "bg" + RUSSIAN = "ru" + UKRAINIAN = "uk" + HINDI = "hi" + THAI = "th" + CHINESE_CHINA = "zh-CN" + JAPANESE = "ja" + CHINESE_TAIWAN = "zh-TW" + KOREAN = "ko" + +class PremiumType(IntEnum): + NONE = 0 + NITRO_CLASSIC = 1 + NITRO = 2 + NITRO_BASIC = 3 \ No newline at end of file From ebb4604f4ea29efb1841fa2a196f74921a685e3b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 14 Feb 2023 13:29:55 +0000 Subject: [PATCH 197/335] :art: Format code automatically --- EpikCord/flags.py | 4 +++- EpikCord/user.py | 30 ++++++++++++++++++++++-------- EpikCord/utils/__init__.py | 5 ++--- EpikCord/utils/enums.py | 1 + 4 files changed, 28 insertions(+), 12 deletions(-) diff --git a/EpikCord/flags.py b/EpikCord/flags.py index 34d7c9c2..daacdb01 100644 --- a/EpikCord/flags.py +++ b/EpikCord/flags.py @@ -127,6 +127,7 @@ class Permissions(Flag): START_EMBEDDED_ACTIVITIES = 1 << 39 MODERATOR_MEMBERS = 1 << 40 + class ChannelFlags(Flag): PINNED = 1 << 1 REQUIRE_TAG = 1 << 4 @@ -143,6 +144,7 @@ class ApplicationFlags(Flag): GATEWAY_MESSAGE_CONTENT_LIMITED = 1 << 19 APPLICATION_COMMAND_BADGE = 1 << 23 + class UserFlags(Flag): STAFF = 1 << 0 PARTNER = 1 << 1 @@ -158,4 +160,4 @@ class UserFlags(Flag): VERIFIED_BOT_DEVELOPER = 1 << 17 CERTIFIED_MODERATOR = 1 << 18 BOT_HTTP_INTERACTIONS = 1 << 19 - ACTIVE_DEVELOPER = 1 << 22 \ No newline at end of file + ACTIVE_DEVELOPER = 1 << 22 diff --git a/EpikCord/user.py b/EpikCord/user.py index b90d94f2..84df9df4 100644 --- a/EpikCord/user.py +++ b/EpikCord/user.py @@ -1,10 +1,10 @@ from typing import Optional -from .utils import Locale, PremiumType -from .flags import UserFlags - from discord_typings import UserData +from .flags import UserFlags +from .utils import Locale, PremiumType + class User: def __init__(self, data: UserData): @@ -16,10 +16,24 @@ def __init__(self, data: UserData): self.system: Optional[bool] = data.get("system") self.mfa_enabled: Optional[bool] = data.get("mfa_enabled") self.banner: Optional[str] = data.get("banner") - self.accent_color: Optional[int] = int(data["accent_color"]) if "accent_color" in data and data["accent_color"] else None - self.locale: Optional[Locale] = Locale(data["locale"]) if "locale" in data else None + self.accent_color: Optional[int] = ( + int(data["accent_color"]) + if "accent_color" in data and data["accent_color"] + else None + ) + self.locale: Optional[Locale] = ( + Locale(data["locale"]) if "locale" in data else None + ) self.verified: Optional[bool] = data.get("verified") self.email: Optional[str] = data.get("email") - self.flags: Optional[UserFlags] = UserFlags(data["flags"]) if "flags" in data else None - self.premium_type: Optional[PremiumType] = PremiumType(data["premium_type"]) if "premium_type" in data else None - self.public_flags: Optional[UserFlags] = UserFlags(data["public_flags"]) if "public_flags" in data else None \ No newline at end of file + self.flags: Optional[UserFlags] = ( + UserFlags(data["flags"]) if "flags" in data else None + ) + self.premium_type: Optional[PremiumType] = ( + PremiumType(data["premium_type"]) + if "premium_type" in data + else None + ) + self.public_flags: Optional[UserFlags] = ( + UserFlags(data["public_flags"]) if "public_flags" in data else None + ) diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py index be664d06..ed086cf6 100644 --- a/EpikCord/utils/__init__.py +++ b/EpikCord/utils/__init__.py @@ -2,13 +2,12 @@ GatewayCloseCode, HTTPCodes, JSONErrorCodes, + Locale, OpCode, + PremiumType, TeamMemberMembershipState, VoiceCloseCode, VoiceOpCode, - TeamMemberMembershipState, - Locale, - PremiumType ) from .loose import ( add_file, diff --git a/EpikCord/utils/enums.py b/EpikCord/utils/enums.py index afc5b1b3..493a4dab 100644 --- a/EpikCord/utils/enums.py +++ b/EpikCord/utils/enums.py @@ -386,6 +386,7 @@ class Locale(Enum): CHINESE_TAIWAN = "zh-TW" KOREAN = "ko" + class PremiumType(IntEnum): NONE = 0 NITRO_CLASSIC = 1 From 9cdc9a9721cd4e2ac7173348be7cf2125e4b9233 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Wed, 15 Feb 2023 17:00:00 +0100 Subject: [PATCH 198/335] :rotating_light: Fix lint issue --- EpikCord/client/websocket.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 9adcafc2..1ef4e14c 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -340,7 +340,8 @@ async def connect(self, *, resume: bool = False): version = self.http.version.value url = ( - f"{self._gateway_url}?v={version}&encoding=json&compress=zlib-stream" + f"{self._gateway_url}?v={version}" + "&encoding=json&compress=zlib-stream" if not resume else self.resume_url ) From 9071d23902b2a81c8acd9e354c463be0953a4943 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Wed, 15 Feb 2023 17:07:05 +0100 Subject: [PATCH 199/335] :rotating_light: Fix docstring line length --- EpikCord/client/http.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 8676f448..f2dfd9de 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -54,13 +54,13 @@ class SessionStartLimit: Attributes ---------- total: int - The total number of sessions that can be started in a 24 hour period. + Total number of sessions that can be started in a 24 hour period. remaining: int - The remaining number of sessions that can be started in this 24 hour period. + Remaining sessions left in 24 hours. reset_after: int - The number of seconds until the remaining number of sessions is reset. + Time in seconds until session limit reset max_concurrency: int - The maximum number of IDENTIFY requests that can be sent in 5 seconds. + Maximum of IDENTIFY requests that can be sent in 5 seconds. """ def __init__(self, data: SessionStartLimitData): From a03d4df1a41a467136be150293d457ce409c7996 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Wed, 15 Feb 2023 17:34:54 +0100 Subject: [PATCH 200/335] :recycle: Improve API class instanciation --- EpikCord/application.py | 89 +++++++++++++++----------------------- EpikCord/user.py | 46 ++++++++------------ EpikCord/utils/__init__.py | 4 ++ EpikCord/utils/loose.py | 16 ++++++- 4 files changed, 72 insertions(+), 83 deletions(-) diff --git a/EpikCord/application.py b/EpikCord/application.py index b19360e0..eba2fec4 100644 --- a/EpikCord/application.py +++ b/EpikCord/application.py @@ -5,78 +5,59 @@ from discord_typings import TeamData, TeamMemberData from .flags import ApplicationFlags, Permissions -from .utils import TeamMemberMembershipState +from .utils import TeamMemberMembershipState, instance_or_none, int_or_none class InstallParams: def __init__(self, data: InstallParamsData): - self.scopes: List[str] = data["scopes"] - self.permissions: Permissions = Permissions(int(data["permissions"])) + self.scopes = data["scopes"] + self.permissions = Permissions(int(data["permissions"])) class TeamMember: def __init__(self, data: TeamMemberData): - self.membership_state: TeamMemberMembershipState = ( + self.membership_state = ( TeamMemberMembershipState(data["membership_state"]) ) - self.permissions: List[str] = data["permissions"] - self.team_id: int = int(data["team_id"]) + self.permissions = data["permissions"] + self.team_id = int(data["team_id"]) self.user = data["user"] # TODO: User Object class Team: def __init__(self, data: TeamData): - self.icon: Optional[str] = data["icon"] - self.id: int = int(data["id"]) - self.members: List[TeamMember] = [ - TeamMember(member) for member in data["members"] - ] - self.name: str = data["name"] - self.owner_user_id: int = int(data["owner_user_id"]) + self.icon = data["icon"] + self.id = int(data["id"]) + self.members = [TeamMember(member) for member in data["members"]] + self.name = data["name"] + self.owner_user_id = int(data["owner_user_id"]) class Application: def __init__(self, data: ApplicationData): - self.id: int = int(data["id"]) - self.name: str = data["name"] - self.icon: Optional[str] = data.get("icon") - self.description: str = data["description"] - self.rpc_origins: Optional[List[str]] = data.get("rpc_origins") - self.bot_public: bool = data["bot_public"] - self.bot_require_code_grant: bool = data["bot_require_code_grant"] - self.terms_of_service_url: Optional[str] = data.get( - "terms_of_service_url" - ) - self.privacy_policy_url: Optional[str] = data.get("privacy_policy_url") + self.id = int(data["id"]) + self.name = data["name"] + self.icon = data.get("icon") + self.description = data["description"] + self.rpc_origins = data.get("rpc_origins") + self.bot_public = data["bot_public"] + self.bot_require_code_grant = data["bot_require_code_grant"] + self.terms_of_service_url = data.get("terms_of_service_url") + self.privacy_policy_url = data.get("privacy_policy_url") self.owner = data.get("owner") # TODO: User Object - self.verify_key: str = data["verify_key"] - self.team: Optional[Team] = Team(data["team"]) if data["team"] else None - self.guild_id: Optional[int] = ( - int(data["guild_id"]) if "guild_id" in data else None - ) - self.primary_sku_id: Optional[int] = ( - int(data["primary_sku_id"]) if "primary_sku_id" in data else None - ) - self.slug: Optional[str] = data["slug"] if "slug" in data else None - self.cover_image: Optional[str] = ( - data["cover_image"] if "cover_image" in data else None - ) - self.flags: Optional[ApplicationFlags] = ( - ApplicationFlags(data["flags"]) if "flags" in data else None - ) # TODO: Values - self.tags: Optional[List[str]] = ( - data["tags"] if "tags" in data else None - ) - self.install_params: Optional[InstallParams] = ( - InstallParams(data["install_params"]) - if "install_params" in data - else None - ) - self.custom_install_url: Optional[str] = ( - data["custom_install_url"] if "custom_install_url" in data else None - ) - self.role_connections_verification_url: Optional[str] = ( - data["role_connections_verification_url"] - if "role_connections_verification_url" in data - else None + self.verify_key = data["verify_key"] + self.team = instance_or_none(Team, data.get("team")) + self.guild_id = int_or_none(data.get("guild_id")) + self.primary_sku_id = int_or_none(data.get("primary_sku_id")) + self.slug = data.get("slug") + self.cover_image = data.get("cover_image") + self.flags = instance_or_none(ApplicationFlags, data.get("flags")) + # TODO: Values + self.tags = data.get("tags") + self.install_params = instance_or_none( + InstallParams, data.get("install_params") + ) + self.custom_install_url = data.get("custom_install_url") + self.role_connections_verification_url = data.get( + "role_connections_verification_url" ) diff --git a/EpikCord/user.py b/EpikCord/user.py index 84df9df4..396089c0 100644 --- a/EpikCord/user.py +++ b/EpikCord/user.py @@ -3,37 +3,27 @@ from discord_typings import UserData from .flags import UserFlags -from .utils import Locale, PremiumType +from .utils import Locale, PremiumType, int_or_none, instance_or_none class User: def __init__(self, data: UserData): - self.id: int = int(data["id"]) - self.username: str = data["username"] - self.discriminator: str = data["discriminator"] - self.avatar: Optional[str] = data.get("avatar") - self.bot: Optional[bool] = data.get("bot") - self.system: Optional[bool] = data.get("system") - self.mfa_enabled: Optional[bool] = data.get("mfa_enabled") - self.banner: Optional[str] = data.get("banner") - self.accent_color: Optional[int] = ( - int(data["accent_color"]) - if "accent_color" in data and data["accent_color"] - else None + self.id = int(data["id"]) + self.username = data["username"] + self.discriminator = data["discriminator"] + self.avatar = data.get("avatar") + self.bot = data.get("bot") + self.system = data.get("system") + self.mfa_enabled = data.get("mfa_enabled") + self.banner = data.get("banner") + self.accent_color = int_or_none(data.get("accent_color")) + self.locale = instance_or_none(Locale, data.get("locale")) + self.verified = data.get("verified") + self.email = data.get("email") + self.flags = instance_or_none(UserFlags, data.get("flags")) + self.premium_type = instance_or_none( + PremiumType, data.get("premium_type") ) - self.locale: Optional[Locale] = ( - Locale(data["locale"]) if "locale" in data else None - ) - self.verified: Optional[bool] = data.get("verified") - self.email: Optional[str] = data.get("email") - self.flags: Optional[UserFlags] = ( - UserFlags(data["flags"]) if "flags" in data else None - ) - self.premium_type: Optional[PremiumType] = ( - PremiumType(data["premium_type"]) - if "premium_type" in data - else None - ) - self.public_flags: Optional[UserFlags] = ( - UserFlags(data["public_flags"]) if "public_flags" in data else None + self.public_flags = instance_or_none( + UserFlags, data.get("public_flags") ) diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py index ed086cf6..d5f0b71b 100644 --- a/EpikCord/utils/__init__.py +++ b/EpikCord/utils/__init__.py @@ -19,6 +19,8 @@ json_serialize, log_request, singleton, + instance_or_none, + int_or_none, ) from .types import ( AsyncFunction, @@ -50,4 +52,6 @@ "json_serialize", "log_request", "singleton", + "instance_or_none", + "int_or_none", ) diff --git a/EpikCord/utils/loose.py b/EpikCord/utils/loose.py index 530bdab2..49fa28fb 100644 --- a/EpikCord/utils/loose.py +++ b/EpikCord/utils/loose.py @@ -1,15 +1,19 @@ from __future__ import annotations import asyncio +from functools import partial from importlib.util import find_spec from logging import getLogger from types import ModuleType -from typing import TYPE_CHECKING, Any, Dict, Optional +from typing import TYPE_CHECKING, Any, Dict, Optional, Type, TypeVar import aiohttp from ..file import File +T = TypeVar('T') + + _ORJSON = find_spec("orjson") json: ModuleType @@ -133,3 +137,13 @@ def add_file( attachment["description"] = file.description return attachment + + +def instance_or_none(cls: Type[T], value: Optional[Any]) -> Optional[T]: + if value is not None: + return None + else: + return cls(value) + + +int_or_none = partial(instance_or_none, cls=int) From 64f092e279229553c1a609307aa21eb6b1bc95bb Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Wed, 15 Feb 2023 17:55:31 +0100 Subject: [PATCH 201/335] :recycle: Shorten some names and remove dead imports --- EpikCord/application.py | 8 +++----- EpikCord/user.py | 2 -- EpikCord/utils/__init__.py | 4 ++-- EpikCord/utils/enums.py | 2 +- EpikCord/utils/loose.py | 2 +- 5 files changed, 7 insertions(+), 11 deletions(-) diff --git a/EpikCord/application.py b/EpikCord/application.py index eba2fec4..c6a7916f 100644 --- a/EpikCord/application.py +++ b/EpikCord/application.py @@ -1,11 +1,9 @@ -from typing import List, Optional - from discord_typings import ApplicationData from discord_typings import InstallParams as InstallParamsData from discord_typings import TeamData, TeamMemberData from .flags import ApplicationFlags, Permissions -from .utils import TeamMemberMembershipState, instance_or_none, int_or_none +from .utils import TeamMembershipState, instance_or_none, int_or_none class InstallParams: @@ -17,7 +15,7 @@ def __init__(self, data: InstallParamsData): class TeamMember: def __init__(self, data: TeamMemberData): self.membership_state = ( - TeamMemberMembershipState(data["membership_state"]) + TeamMembershipState(data["membership_state"]) ) self.permissions = data["permissions"] self.team_id = int(data["team_id"]) @@ -58,6 +56,6 @@ def __init__(self, data: ApplicationData): InstallParams, data.get("install_params") ) self.custom_install_url = data.get("custom_install_url") - self.role_connections_verification_url = data.get( + self.role_conn_verification_url = data.get( "role_connections_verification_url" ) diff --git a/EpikCord/user.py b/EpikCord/user.py index 396089c0..acd2dde7 100644 --- a/EpikCord/user.py +++ b/EpikCord/user.py @@ -1,5 +1,3 @@ -from typing import Optional - from discord_typings import UserData from .flags import UserFlags diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py index d5f0b71b..711545f4 100644 --- a/EpikCord/utils/__init__.py +++ b/EpikCord/utils/__init__.py @@ -5,7 +5,7 @@ Locale, OpCode, PremiumType, - TeamMemberMembershipState, + TeamMembershipState, VoiceCloseCode, VoiceOpCode, ) @@ -40,7 +40,7 @@ "SendingAttachmentData", "VoiceCloseCode", "VoiceOpCode", - "TeamMemberMembershipState", + "TeamMembershipState", "Locale", "PremiumType", "add_file", diff --git a/EpikCord/utils/enums.py b/EpikCord/utils/enums.py index 493a4dab..f628d451 100644 --- a/EpikCord/utils/enums.py +++ b/EpikCord/utils/enums.py @@ -346,7 +346,7 @@ def _missing_(cls, value: object) -> JSONErrorCodes: return cls.GENERAL_ERROR -class TeamMemberMembershipState(IntEnum): +class TeamMembershipState(IntEnum): """The state of a team member.""" INVITED = 1 diff --git a/EpikCord/utils/loose.py b/EpikCord/utils/loose.py index 49fa28fb..b07658d0 100644 --- a/EpikCord/utils/loose.py +++ b/EpikCord/utils/loose.py @@ -5,7 +5,7 @@ from importlib.util import find_spec from logging import getLogger from types import ModuleType -from typing import TYPE_CHECKING, Any, Dict, Optional, Type, TypeVar +from typing import TYPE_CHECKING, Any, Dict, Optional, Type, TypeVar, Callable import aiohttp From a2f2442ffac4702bb6593084077bb7a50f385216 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 15 Feb 2023 16:57:46 +0000 Subject: [PATCH 202/335] :art: Format code automatically --- EpikCord/application.py | 4 +--- EpikCord/user.py | 2 +- EpikCord/utils/__init__.py | 4 ++-- EpikCord/utils/loose.py | 4 ++-- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/EpikCord/application.py b/EpikCord/application.py index c6a7916f..4ad85dce 100644 --- a/EpikCord/application.py +++ b/EpikCord/application.py @@ -14,9 +14,7 @@ def __init__(self, data: InstallParamsData): class TeamMember: def __init__(self, data: TeamMemberData): - self.membership_state = ( - TeamMembershipState(data["membership_state"]) - ) + self.membership_state = TeamMembershipState(data["membership_state"]) self.permissions = data["permissions"] self.team_id = int(data["team_id"]) self.user = data["user"] # TODO: User Object diff --git a/EpikCord/user.py b/EpikCord/user.py index acd2dde7..4f741af3 100644 --- a/EpikCord/user.py +++ b/EpikCord/user.py @@ -1,7 +1,7 @@ from discord_typings import UserData from .flags import UserFlags -from .utils import Locale, PremiumType, int_or_none, instance_or_none +from .utils import Locale, PremiumType, instance_or_none, int_or_none class User: diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py index 711545f4..dff515d2 100644 --- a/EpikCord/utils/__init__.py +++ b/EpikCord/utils/__init__.py @@ -16,11 +16,11 @@ cleanup_loop, clear_none_values, extract_content, + instance_or_none, + int_or_none, json_serialize, log_request, singleton, - instance_or_none, - int_or_none, ) from .types import ( AsyncFunction, diff --git a/EpikCord/utils/loose.py b/EpikCord/utils/loose.py index b07658d0..556b0a8a 100644 --- a/EpikCord/utils/loose.py +++ b/EpikCord/utils/loose.py @@ -5,13 +5,13 @@ from importlib.util import find_spec from logging import getLogger from types import ModuleType -from typing import TYPE_CHECKING, Any, Dict, Optional, Type, TypeVar, Callable +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Type, TypeVar import aiohttp from ..file import File -T = TypeVar('T') +T = TypeVar("T") _ORJSON = find_spec("orjson") From 6cab95d609854f2abd210d7f8aa1a502a941723e Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Wed, 15 Feb 2023 18:16:59 +0100 Subject: [PATCH 203/335] :rotating_light: Fix lint issue --- EpikCord/utils/loose.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EpikCord/utils/loose.py b/EpikCord/utils/loose.py index 556b0a8a..c62605a4 100644 --- a/EpikCord/utils/loose.py +++ b/EpikCord/utils/loose.py @@ -5,7 +5,7 @@ from importlib.util import find_spec from logging import getLogger from types import ModuleType -from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Type, TypeVar +from typing import TYPE_CHECKING, Any, Dict, Optional, Type, TypeVar import aiohttp From a8913e2149341cffcb4c198b84d13191177fbe04 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Wed, 15 Feb 2023 18:17:28 +0100 Subject: [PATCH 204/335] :green_heart: Fix coverage nox session name --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e601b292..15b58ad3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -204,7 +204,7 @@ jobs: run: pip install nox git+https://github.com/Bluenix2/discord-typings - name: Run coverage - run: nox -s coverage_report + run: nox -s cov - name: Upload coverage to Codecov uses: codecov/codecov-action@v2 From eadbdf2d93ec7c0891c6dbace15d7c962ef21542 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Wed, 15 Feb 2023 17:48:26 +0000 Subject: [PATCH 205/335] Add _data attribute to data classes --- EpikCord/application.py | 4 ++++ EpikCord/presence.py | 1 + EpikCord/user.py | 1 + 3 files changed, 6 insertions(+) diff --git a/EpikCord/application.py b/EpikCord/application.py index b19360e0..75044912 100644 --- a/EpikCord/application.py +++ b/EpikCord/application.py @@ -12,6 +12,7 @@ class InstallParams: def __init__(self, data: InstallParamsData): self.scopes: List[str] = data["scopes"] self.permissions: Permissions = Permissions(int(data["permissions"])) + self._data: InstallParamsData = data class TeamMember: @@ -22,6 +23,7 @@ def __init__(self, data: TeamMemberData): self.permissions: List[str] = data["permissions"] self.team_id: int = int(data["team_id"]) self.user = data["user"] # TODO: User Object + self._data: TeamMemberData = data class Team: @@ -33,6 +35,7 @@ def __init__(self, data: TeamData): ] self.name: str = data["name"] self.owner_user_id: int = int(data["owner_user_id"]) + self._data: TeamData = data class Application: @@ -80,3 +83,4 @@ def __init__(self, data: ApplicationData): if "role_connections_verification_url" in data else None ) + self._data: ApplicationData = data \ No newline at end of file diff --git a/EpikCord/presence.py b/EpikCord/presence.py index df8d148c..14a97923 100644 --- a/EpikCord/presence.py +++ b/EpikCord/presence.py @@ -105,3 +105,4 @@ def to_dict(self) -> UpdatePresenceData: data["activities"] = [self.activity.to_dict()] return data + diff --git a/EpikCord/user.py b/EpikCord/user.py index 84df9df4..0af14f05 100644 --- a/EpikCord/user.py +++ b/EpikCord/user.py @@ -37,3 +37,4 @@ def __init__(self, data: UserData): self.public_flags: Optional[UserFlags] = ( UserFlags(data["public_flags"]) if "public_flags" in data else None ) + self._data: UserData = data From ace048cabe453e28856b830e6de3ff7f8788fb53 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Wed, 15 Feb 2023 18:51:26 +0100 Subject: [PATCH 206/335] :rewind: Rollback name change for "role_connections_verification_url" --- EpikCord/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EpikCord/application.py b/EpikCord/application.py index 4ad85dce..305cac59 100644 --- a/EpikCord/application.py +++ b/EpikCord/application.py @@ -54,6 +54,6 @@ def __init__(self, data: ApplicationData): InstallParams, data.get("install_params") ) self.custom_install_url = data.get("custom_install_url") - self.role_conn_verification_url = data.get( + self.role_connections_verification_url = data.get( "role_connections_verification_url" ) From b3da3c957360c187a6a2998e7ef51c8ed0cb780f Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Wed, 15 Feb 2023 18:56:29 +0100 Subject: [PATCH 207/335] :white_check_mark: Add test for None Value --- EpikCord/utils/loose.py | 2 +- tests/unit/test_instance_or_none.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_instance_or_none.py diff --git a/EpikCord/utils/loose.py b/EpikCord/utils/loose.py index c62605a4..997eadbd 100644 --- a/EpikCord/utils/loose.py +++ b/EpikCord/utils/loose.py @@ -140,7 +140,7 @@ def add_file( def instance_or_none(cls: Type[T], value: Optional[Any]) -> Optional[T]: - if value is not None: + if value is None: return None else: return cls(value) diff --git a/tests/unit/test_instance_or_none.py b/tests/unit/test_instance_or_none.py new file mode 100644 index 00000000..edf2ff45 --- /dev/null +++ b/tests/unit/test_instance_or_none.py @@ -0,0 +1,29 @@ +from EpikCord.utils import instance_or_none + + +class Test: + + def __init__(self, value: int): + self.value = value + + def __eq__(self, other): + if not isinstance(other, Test): + raise TypeError + + return self.value == other.value + + +def test_none_value(): + assert instance_or_none(list, None) is None + assert instance_or_none(str, None) is None + assert instance_or_none(Test, None) is None + + +def test_falsy_value(): + assert instance_or_none(Test, False) == Test(False) + + +def test_proper_value(): + assert instance_or_none(Test, 4) == Test(4) + assert instance_or_none(Test, 8) == Test(8) + assert instance_or_none(Test, 42) == Test(42) From 22d2fac3568f2798230bf65f3aee696d1f359ad9 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Wed, 15 Feb 2023 19:00:36 +0100 Subject: [PATCH 208/335] :green_heart: Fix coverage report CI for rewrite --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e601b292..15b58ad3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -204,7 +204,7 @@ jobs: run: pip install nox git+https://github.com/Bluenix2/discord-typings - name: Run coverage - run: nox -s coverage_report + run: nox -s cov - name: Upload coverage to Codecov uses: codecov/codecov-action@v2 From c94b9e74f5341f4e7d8d332e33fbede6fcf28665 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Wed, 15 Feb 2023 20:36:42 +0100 Subject: [PATCH 209/335] :white_check_mark: Add minor unittests --- tests/unit/test_file.py | 8 ++++++++ tests/unit/test_intents.py | 9 +++++++++ 2 files changed, 17 insertions(+) diff --git a/tests/unit/test_file.py b/tests/unit/test_file.py index bfb650ae..629f90ac 100644 --- a/tests/unit/test_file.py +++ b/tests/unit/test_file.py @@ -1,6 +1,9 @@ import io +import pytest + from EpikCord.file import File +from EpikCord.exceptions import UnknownMimeType def test_file(): @@ -17,3 +20,8 @@ def test_file_spoiler(): assert file.filename == "SPOILER_test.txt" assert file.mime_type == "text/plain" assert file.description is None + + +def test_unknown_mime(): + with pytest.raises(UnknownMimeType): + assert File("failing", io.BytesIO(b"\xef")) diff --git a/tests/unit/test_intents.py b/tests/unit/test_intents.py index 59e6a0fa..fc82d5d5 100644 --- a/tests/unit/test_intents.py +++ b/tests/unit/test_intents.py @@ -32,3 +32,12 @@ def test_intents_all(): assert intents.turned_on == members[1:] assert not intents.GUILDS + + +def test_intents_value(): + intents = Intents() + + assert intents.value == 0 + intents.GUILDS = True + + assert intents.value == intents.GUILDS From a465447764439d918abd240fd1d70a702d8d3e9d Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Wed, 15 Feb 2023 20:42:54 +0100 Subject: [PATCH 210/335] :green_heart: Ignore git folder --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15b58ad3..dacf66a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,7 +61,7 @@ jobs: python-version: 3.8 cache: 'pip' - run: pip install codespell - - run: codespell --skip="./docs/*" + - run: codespell --skip="./docs/* ./git/*" lint: if: ${{ always() }} From 1cd88be7a133cedc2bcc00975c8cd321809c9e0d Mon Sep 17 00:00:00 2001 From: Yohann Boniface Date: Thu, 16 Feb 2023 12:21:49 +0100 Subject: [PATCH 211/335] :fire: Remove uneeded comment --- EpikCord/application.py | 1 - 1 file changed, 1 deletion(-) diff --git a/EpikCord/application.py b/EpikCord/application.py index 305cac59..bb9569d0 100644 --- a/EpikCord/application.py +++ b/EpikCord/application.py @@ -48,7 +48,6 @@ def __init__(self, data: ApplicationData): self.slug = data.get("slug") self.cover_image = data.get("cover_image") self.flags = instance_or_none(ApplicationFlags, data.get("flags")) - # TODO: Values self.tags = data.get("tags") self.install_params = instance_or_none( InstallParams, data.get("install_params") From 27ce860133ad1403368ff083615ede300268672e Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Thu, 16 Feb 2023 11:34:46 +0000 Subject: [PATCH 212/335] Start on Interaction --- EpikCord/utils/__init__.py | 4 ++++ EpikCord/utils/enums.py | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py index ed086cf6..b1522b20 100644 --- a/EpikCord/utils/__init__.py +++ b/EpikCord/utils/__init__.py @@ -8,6 +8,8 @@ TeamMemberMembershipState, VoiceCloseCode, VoiceOpCode, + InteractionType, + StatusCode ) from .loose import ( add_file, @@ -39,6 +41,8 @@ "VoiceCloseCode", "VoiceOpCode", "TeamMemberMembershipState", + "StatusCode", + "InteractionType", "Locale", "PremiumType", "add_file", diff --git a/EpikCord/utils/enums.py b/EpikCord/utils/enums.py index 493a4dab..a26a4516 100644 --- a/EpikCord/utils/enums.py +++ b/EpikCord/utils/enums.py @@ -392,3 +392,10 @@ class PremiumType(IntEnum): NITRO_CLASSIC = 1 NITRO = 2 NITRO_BASIC = 3 + +class InteractionType(IntEnum): + PING = 1 + APPLICATION_COMMAND = 2 + MESSAGE_COMPONENT = 3 + APPLICATION_COMMAND_AUTOCOMPLETE = 4 + MODAL_SUBMIT = 5 \ No newline at end of file From 9f03c5f3bffe440a75d530e0e16fa5c12df2ab69 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 16 Feb 2023 11:38:14 +0000 Subject: [PATCH 213/335] :art: Format code automatically --- EpikCord/application.py | 2 +- EpikCord/presence.py | 1 - EpikCord/utils/__init__.py | 4 ++-- EpikCord/utils/enums.py | 3 ++- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/EpikCord/application.py b/EpikCord/application.py index df0377ef..d6fea815 100644 --- a/EpikCord/application.py +++ b/EpikCord/application.py @@ -57,4 +57,4 @@ def __init__(self, data: ApplicationData): self.role_connections_verification_url = data.get( "role_connections_verification_url" ) - self._data: ApplicationData = data \ No newline at end of file + self._data: ApplicationData = data diff --git a/EpikCord/presence.py b/EpikCord/presence.py index 14a97923..df8d148c 100644 --- a/EpikCord/presence.py +++ b/EpikCord/presence.py @@ -105,4 +105,3 @@ def to_dict(self) -> UpdatePresenceData: data["activities"] = [self.activity.to_dict()] return data - diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py index 1c80fe6f..3c3caf3a 100644 --- a/EpikCord/utils/__init__.py +++ b/EpikCord/utils/__init__.py @@ -1,15 +1,15 @@ from .enums import ( GatewayCloseCode, HTTPCodes, + InteractionType, JSONErrorCodes, Locale, OpCode, PremiumType, + StatusCode, TeamMembershipState, VoiceCloseCode, VoiceOpCode, - InteractionType, - StatusCode ) from .loose import ( add_file, diff --git a/EpikCord/utils/enums.py b/EpikCord/utils/enums.py index 2d25873e..27818f8f 100644 --- a/EpikCord/utils/enums.py +++ b/EpikCord/utils/enums.py @@ -393,9 +393,10 @@ class PremiumType(IntEnum): NITRO = 2 NITRO_BASIC = 3 + class InteractionType(IntEnum): PING = 1 APPLICATION_COMMAND = 2 MESSAGE_COMPONENT = 3 APPLICATION_COMMAND_AUTOCOMPLETE = 4 - MODAL_SUBMIT = 5 \ No newline at end of file + MODAL_SUBMIT = 5 From ec99b6f115d3039ad02bc02bb02633c8a0d5d6f7 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Thu, 16 Feb 2023 14:56:59 +0000 Subject: [PATCH 214/335] Add GuildMember object --- EpikCord/guild.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 EpikCord/guild.py diff --git a/EpikCord/guild.py b/EpikCord/guild.py new file mode 100644 index 00000000..0a2d0b3c --- /dev/null +++ b/EpikCord/guild.py @@ -0,0 +1,37 @@ +from datetime import datetime + +from discord_typings import GuildMemberData + +from .client import Client +from .flags import GuildMemberFlags, Permissions +from .user import User +from .utils import instance_or_none + + +class GuildMember: + def __init__(self, client: Client, data: GuildMemberData): + self.client = client + self.user = instance_or_none( + User, data.get("user"), client, data.get("user"), ignore_value=True + ) + self.nick = data.get("nick") + self.avatar = data.get("avatar") + self.roles = data.get("roles") # TODO: Add Role Object + self.joined_at = ( + datetime.fromisoformat(data["joined_at"]) + if data.get("joined_at") + else None + ) + self.premium_since = instance_or_none( + datetime.fromisoformat, data.get("premium_since") + ) + self.deaf = data["deaf"] + self.mute = data["mute"] + self.flags = GuildMemberFlags(data["flags"]) # type: ignore # DiscordTyping/32 + self.pending = data.get("pending") + self.permissions = instance_or_none( + Permissions, data.get("permissions") + ) + self.communication_disabled_until = instance_or_none( + datetime.fromisoformat, data.get("communication_disabled_until") + ) From f36f29b3eef98435155a4050598efd36fd6c061d Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Thu, 16 Feb 2023 14:57:08 +0000 Subject: [PATCH 215/335] Add flags --- EpikCord/flags.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/EpikCord/flags.py b/EpikCord/flags.py index daacdb01..47d4f4c5 100644 --- a/EpikCord/flags.py +++ b/EpikCord/flags.py @@ -161,3 +161,10 @@ class UserFlags(Flag): CERTIFIED_MODERATOR = 1 << 18 BOT_HTTP_INTERACTIONS = 1 << 19 ACTIVE_DEVELOPER = 1 << 22 + + +class GuildMemberFlags(Flag): + DID_REJOIN = 1 << 0 + COMPLETED_ONBOARDING = 1 << 1 + BYPASSES_VERIFICATION = 1 << 2 + STARTED_ONBOARDING = 1 << 3 From adeaf416612704f05b40dcbc06e1b3f9bd4a71a9 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Thu, 16 Feb 2023 14:57:23 +0000 Subject: [PATCH 216/335] Make instance_or_none more flexible --- EpikCord/utils/__init__.py | 2 ++ EpikCord/utils/enums.py | 5 +++++ EpikCord/utils/loose.py | 24 ++++++++++++++++++++---- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py index 3c3caf3a..72f93922 100644 --- a/EpikCord/utils/__init__.py +++ b/EpikCord/utils/__init__.py @@ -10,6 +10,7 @@ TeamMembershipState, VoiceCloseCode, VoiceOpCode, + ApplicationCommandType ) from .loose import ( add_file, @@ -44,6 +45,7 @@ "VoiceOpCode", "StatusCode", "InteractionType", + "ApplicationCommandType", "TeamMembershipState", "Locale", "PremiumType", diff --git a/EpikCord/utils/enums.py b/EpikCord/utils/enums.py index 27818f8f..36d558d0 100644 --- a/EpikCord/utils/enums.py +++ b/EpikCord/utils/enums.py @@ -400,3 +400,8 @@ class InteractionType(IntEnum): MESSAGE_COMPONENT = 3 APPLICATION_COMMAND_AUTOCOMPLETE = 4 MODAL_SUBMIT = 5 + +class ApplicationCommandType(IntEnum): + CHAT_INPUT = 1 + USER = 2 + MESSAGE = 3 \ No newline at end of file diff --git a/EpikCord/utils/loose.py b/EpikCord/utils/loose.py index 997eadbd..78e7d92d 100644 --- a/EpikCord/utils/loose.py +++ b/EpikCord/utils/loose.py @@ -5,7 +5,16 @@ from importlib.util import find_spec from logging import getLogger from types import ModuleType -from typing import TYPE_CHECKING, Any, Dict, Optional, Type, TypeVar +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Dict, + Optional, + Type, + TypeVar, + Union, +) import aiohttp @@ -139,11 +148,18 @@ def add_file( return attachment -def instance_or_none(cls: Type[T], value: Optional[Any]) -> Optional[T]: +def instance_or_none( + cls: Union[Type[T], Callable[..., Any]], + value: Optional[Any], + *args, + ignore_value: bool = False, + **kwargs, +) -> Optional[T]: if value is None: return None - else: - return cls(value) + if ignore_value: + return cls(*args, **kwargs) + return cls(value, *args, **kwargs) int_or_none = partial(instance_or_none, cls=int) From 52f42abea66d8d2e606d3ee67afea1990e449c64 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Thu, 16 Feb 2023 14:57:40 +0000 Subject: [PATCH 217/335] Remove duplicate name --- noxfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index 36fa8e2e..09c31ac2 100644 --- a/noxfile.py +++ b/noxfile.py @@ -37,7 +37,7 @@ def check_circular_imports(session: nox.Session): @nox.session(name="unit") -def end_to_end(session: nox.Session): +def unit(session: nox.Session): session.install("pytest") session.install("-r", "requirements.txt") session.install(".") From 5ccd9c5afba8af3e230b9f470778442be0f56cbf Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Thu, 16 Feb 2023 14:57:55 +0000 Subject: [PATCH 218/335] Make User take in Client --- EpikCord/user.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/EpikCord/user.py b/EpikCord/user.py index 55be0097..b33e9d1d 100644 --- a/EpikCord/user.py +++ b/EpikCord/user.py @@ -1,11 +1,13 @@ from discord_typings import UserData +from .client import Client from .flags import UserFlags from .utils import Locale, PremiumType, instance_or_none, int_or_none class User: - def __init__(self, data: UserData): + def __init__(self, client: Client, data: UserData): + self.client = client self.id = int(data["id"]) self.username = data["username"] self.discriminator = data["discriminator"] From f2f0812493f904c76a63fa2f6eee9fd19ced4071 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Thu, 16 Feb 2023 15:23:54 +0000 Subject: [PATCH 219/335] Start off interactions.poy --- EpikCord/file.py | 18 +++++++++++++ EpikCord/guild.py | 33 ++++++++++++++++++++--- EpikCord/interaction.py | 60 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 EpikCord/interaction.py diff --git a/EpikCord/file.py b/EpikCord/file.py index 35492117..6d928c38 100644 --- a/EpikCord/file.py +++ b/EpikCord/file.py @@ -2,9 +2,27 @@ import mimetypes from typing import Optional +from discord_typings import AttachmentData + +from .client import Client from .exceptions import UnknownMimeType +class Attachment: + def __init__(self, data: AttachmentData): + self.id = int(data["id"]) + self.filename = data["filename"] + self.size = data["size"] + self.url = data["url"] + self.proxy_url = data["proxy_url"] + self.description = data.get("description") + self.content_type = data.get("content_type") + self.height = data.get("height") + self.width = data.get("width") + self.ephemeral = data.get("ephemeral") + self._data = data + + class File: def __init__( self, diff --git a/EpikCord/guild.py b/EpikCord/guild.py index 0a2d0b3c..0112b8b2 100644 --- a/EpikCord/guild.py +++ b/EpikCord/guild.py @@ -1,11 +1,11 @@ from datetime import datetime -from discord_typings import GuildMemberData +from discord_typings import GuildMemberData, RoleData, RoleTagsData from .client import Client from .flags import GuildMemberFlags, Permissions from .user import User -from .utils import instance_or_none +from .utils import instance_or_none, int_or_none class GuildMember: @@ -16,7 +16,8 @@ def __init__(self, client: Client, data: GuildMemberData): ) self.nick = data.get("nick") self.avatar = data.get("avatar") - self.roles = data.get("roles") # TODO: Add Role Object + # self.roles = [Role(client, role) for role in data.get("roles", [])] + self.roles = data["roles"] self.joined_at = ( datetime.fromisoformat(data["joined_at"]) if data.get("joined_at") @@ -35,3 +36,29 @@ def __init__(self, client: Client, data: GuildMemberData): self.communication_disabled_until = instance_or_none( datetime.fromisoformat, data.get("communication_disabled_until") ) + +class RoleTags: + def __init__(self, data: RoleTagsData): + self._data = data + self.bot_id = int_or_none(data.get("bot_id")) + self.integration_id = int_or_none(data.get("integration_id")) + self.premium_subscriber = True if data.get("premium_subscriber") else False + self.subscription_listing_id = int_or_none(data.get("subscription_listing_id")) + self.available_for_purchase = True if data.get("available_for_purchase") else False + self.guild_connections = True if data.get("guild_connections") else False + +class Role: + def __init__(self, client: Client, data: RoleData): + self.client = client + self._data = data + self.id = int(data["id"]) + self.name = data["name"] + self.color = int(data["color"]) + self.hoist = data["hoist"] + self.icon = data.get("icon") + self.unicode_emoji = data.get("unicode_emoji") + self.position = data["position"] + self.permissions = Permissions(int(data["permissions"])) + self.managed = data["managed"] + self.mentionable = data["mentionable"] + self.tags = instance_or_none(RoleTags, data.get("tags")) \ No newline at end of file diff --git a/EpikCord/interaction.py b/EpikCord/interaction.py new file mode 100644 index 00000000..69854698 --- /dev/null +++ b/EpikCord/interaction.py @@ -0,0 +1,60 @@ +from discord_typings import ApplicationCommandInteractionData, InteractionData, ResolvedInteractionDataData + +from .client import Client +from .file import Attachment +from .flags import Permissions +from .guild import GuildMember, Role +from .user import User +from .utils import InteractionType, Locale, instance_or_none, int_or_none, ApplicationCommandType + +class BaseInteraction: + def __init__(self, client: Client, data: InteractionData): + self.client: Client = client + self._data = data + self.id = int(data["id"]) + self.application_id = int(data["application_id"]) + self.type: InteractionType = InteractionType(data["type"]) + self.guild_id = int_or_none(data.get("guild_id")) + self.channel_id = int_or_none(data.get("channel_id")) + self.author = instance_or_none( + User, data.get("user"), client, data.get("user"), ignore_value=True + ) or instance_or_none( + GuildMember, + data.get("member"), + client, + data.get("member"), + ignore_value=True, + ) + self.token = data["token"] + self.version = data["version"] + self.app_permissions = instance_or_none( + Permissions, data.get("app_permissions") + ) + self.locale = instance_or_none(Locale, data.get("locale")) + self.guild_locale = instance_or_none(Locale, data.get("guild_locale")) + +class ResolvedInteractionData: + def __init__(self, client: Client, data: ResolvedInteractionDataData): + self.users = {int(k): User(client, v) for k, v in data.get("users", {}).items()} + self.members = {int(k): GuildMember(client, v) for k, v in data.get("members", {}).items()} + self.roles = {int(k): Role(client, v) for k, v in data.get("roles", {}).items()} + # CHANNELS + # MESSAGES + self.attachments = {int(k): Attachment(v) for k, v in data.get("attachments", {}).items()} + + +class BaseApplicationCommandInteraction(BaseInteraction): + def __init__(self, client: Client, data: ApplicationCommandInteractionData): + super().__init__(client, data) + self.data = data["data"] + self.command_id = int(self.data["id"]) + self.command_name = self.data["name"] + self.command_type: ApplicationCommandType = ApplicationCommandType(self.data["type"]) + self.resolved = instance_or_none(ResolvedInteractionData, data.get("resolved")) + +# class ChatInputCommandInteraction(): +# def __init__(self, client: Client, data: ApplicationCommandInteractionData): +# super().__init__(client, data) +# # OPTIONS + +# class ContextMenuInteraction(BaseInteraction): \ No newline at end of file From fde3de68b3b6e94f83f1525f47a68fbd2fc3bee5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 16 Feb 2023 15:24:56 +0000 Subject: [PATCH 220/335] :art: Format code automatically --- EpikCord/guild.py | 20 ++++++++++++----- EpikCord/interaction.py | 45 ++++++++++++++++++++++++++++++-------- EpikCord/utils/__init__.py | 2 +- EpikCord/utils/enums.py | 3 ++- 4 files changed, 54 insertions(+), 16 deletions(-) diff --git a/EpikCord/guild.py b/EpikCord/guild.py index 0112b8b2..0fcf4624 100644 --- a/EpikCord/guild.py +++ b/EpikCord/guild.py @@ -37,15 +37,25 @@ def __init__(self, client: Client, data: GuildMemberData): datetime.fromisoformat, data.get("communication_disabled_until") ) + class RoleTags: def __init__(self, data: RoleTagsData): self._data = data self.bot_id = int_or_none(data.get("bot_id")) self.integration_id = int_or_none(data.get("integration_id")) - self.premium_subscriber = True if data.get("premium_subscriber") else False - self.subscription_listing_id = int_or_none(data.get("subscription_listing_id")) - self.available_for_purchase = True if data.get("available_for_purchase") else False - self.guild_connections = True if data.get("guild_connections") else False + self.premium_subscriber = ( + True if data.get("premium_subscriber") else False + ) + self.subscription_listing_id = int_or_none( + data.get("subscription_listing_id") + ) + self.available_for_purchase = ( + True if data.get("available_for_purchase") else False + ) + self.guild_connections = ( + True if data.get("guild_connections") else False + ) + class Role: def __init__(self, client: Client, data: RoleData): @@ -61,4 +71,4 @@ def __init__(self, client: Client, data: RoleData): self.permissions = Permissions(int(data["permissions"])) self.managed = data["managed"] self.mentionable = data["mentionable"] - self.tags = instance_or_none(RoleTags, data.get("tags")) \ No newline at end of file + self.tags = instance_or_none(RoleTags, data.get("tags")) diff --git a/EpikCord/interaction.py b/EpikCord/interaction.py index 69854698..cdef8674 100644 --- a/EpikCord/interaction.py +++ b/EpikCord/interaction.py @@ -1,11 +1,22 @@ -from discord_typings import ApplicationCommandInteractionData, InteractionData, ResolvedInteractionDataData +from discord_typings import ( + ApplicationCommandInteractionData, + InteractionData, + ResolvedInteractionDataData, +) from .client import Client from .file import Attachment from .flags import Permissions from .guild import GuildMember, Role from .user import User -from .utils import InteractionType, Locale, instance_or_none, int_or_none, ApplicationCommandType +from .utils import ( + ApplicationCommandType, + InteractionType, + Locale, + instance_or_none, + int_or_none, +) + class BaseInteraction: def __init__(self, client: Client, data: InteractionData): @@ -33,14 +44,25 @@ def __init__(self, client: Client, data: InteractionData): self.locale = instance_or_none(Locale, data.get("locale")) self.guild_locale = instance_or_none(Locale, data.get("guild_locale")) + class ResolvedInteractionData: def __init__(self, client: Client, data: ResolvedInteractionDataData): - self.users = {int(k): User(client, v) for k, v in data.get("users", {}).items()} - self.members = {int(k): GuildMember(client, v) for k, v in data.get("members", {}).items()} - self.roles = {int(k): Role(client, v) for k, v in data.get("roles", {}).items()} + self.users = { + int(k): User(client, v) for k, v in data.get("users", {}).items() + } + self.members = { + int(k): GuildMember(client, v) + for k, v in data.get("members", {}).items() + } + self.roles = { + int(k): Role(client, v) for k, v in data.get("roles", {}).items() + } # CHANNELS # MESSAGES - self.attachments = {int(k): Attachment(v) for k, v in data.get("attachments", {}).items()} + self.attachments = { + int(k): Attachment(v) + for k, v in data.get("attachments", {}).items() + } class BaseApplicationCommandInteraction(BaseInteraction): @@ -49,12 +71,17 @@ def __init__(self, client: Client, data: ApplicationCommandInteractionData): self.data = data["data"] self.command_id = int(self.data["id"]) self.command_name = self.data["name"] - self.command_type: ApplicationCommandType = ApplicationCommandType(self.data["type"]) - self.resolved = instance_or_none(ResolvedInteractionData, data.get("resolved")) + self.command_type: ApplicationCommandType = ApplicationCommandType( + self.data["type"] + ) + self.resolved = instance_or_none( + ResolvedInteractionData, data.get("resolved") + ) + # class ChatInputCommandInteraction(): # def __init__(self, client: Client, data: ApplicationCommandInteractionData): # super().__init__(client, data) # # OPTIONS -# class ContextMenuInteraction(BaseInteraction): \ No newline at end of file +# class ContextMenuInteraction(BaseInteraction): diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py index 72f93922..dfee4df4 100644 --- a/EpikCord/utils/__init__.py +++ b/EpikCord/utils/__init__.py @@ -1,4 +1,5 @@ from .enums import ( + ApplicationCommandType, GatewayCloseCode, HTTPCodes, InteractionType, @@ -10,7 +11,6 @@ TeamMembershipState, VoiceCloseCode, VoiceOpCode, - ApplicationCommandType ) from .loose import ( add_file, diff --git a/EpikCord/utils/enums.py b/EpikCord/utils/enums.py index 36d558d0..5f142f54 100644 --- a/EpikCord/utils/enums.py +++ b/EpikCord/utils/enums.py @@ -401,7 +401,8 @@ class InteractionType(IntEnum): APPLICATION_COMMAND_AUTOCOMPLETE = 4 MODAL_SUBMIT = 5 + class ApplicationCommandType(IntEnum): CHAT_INPUT = 1 USER = 2 - MESSAGE = 3 \ No newline at end of file + MESSAGE = 3 From f7dd4d13bf2748614409e2cce729c43bf5cfe16f Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Thu, 16 Feb 2023 17:36:20 +0000 Subject: [PATCH 221/335] Start working on client commands --- EpikCord/client/commands.py | 57 +++++++++++++++++++++++++++++ EpikCord/guild.py | 20 +++++++--- EpikCord/interaction.py | 45 ++++++++++++++++++----- EpikCord/locales.py | 12 ++++++ EpikCord/utils/__init__.py | 4 +- EpikCord/utils/enums.py | 3 +- EpikCord/utils/loose.py | 12 ++++++ tests/unit/test_clean_url.py | 12 ++---- tests/unit/test_instance_or_none.py | 1 - tests/unit/test_json_serialize.py | 4 +- tests/unit/test_singleton.py | 1 - 11 files changed, 143 insertions(+), 28 deletions(-) create mode 100644 EpikCord/client/commands.py create mode 100644 EpikCord/locales.py diff --git a/EpikCord/client/commands.py b/EpikCord/client/commands.py new file mode 100644 index 00000000..346a1bec --- /dev/null +++ b/EpikCord/client/commands.py @@ -0,0 +1,57 @@ +from typing import List, Optional + +from ..flags import Permissions +from ..locales import Localization +from ..utils import AsyncFunction, ApplicationCommandType, localization_list_to_dict + + +class BaseClientCommand: + def __init__( + self, + name: str, + # description: str, + callback: AsyncFunction, + *, + guild_ids: List[int] = [], + name_localizations: List[Localization] = [], + # description_localizations: List[Localization] = [], + type: ApplicationCommandType = ApplicationCommandType.CHAT_INPUT, + guild_only: bool = False, + default_member_permissions: Optional[Permissions] = None, + nsfw: bool = False, + ): + self.name = name + # self.description = description + self.guild_ids = guild_ids + self.callback = callback + self.name_localizations = name_localizations + # self.description_localizations = description_localizations + self.type = type + self.guild_only = guild_only + self.default_member_permissions = default_member_permissions + self.nsfw = nsfw + + def to_dict(self): + + payload = { + "name": self.name, + # "description": self.description, + "type": self.type.value, + "nsfw": self.nsfw, + } + + if self.guild_ids: + payload["guild_ids"] = self.guild_ids + if self.default_member_permissions: + payload["default_member_permissions"] = self.default_member_permissions.value + if self.guild_only is not None: + payload["dm_permission"] = not self.guild_only + if name_localizations := localization_list_to_dict(self.name_localizations): + payload["name_localizations"] = name_localizations + # if description_localizations := localization_list_to_dict(self.description_localizations): + # payload["description_localizations"] = description_localizations + + return payload + +class ClientContextMenuCommand(BaseClientCommand): + ... \ No newline at end of file diff --git a/EpikCord/guild.py b/EpikCord/guild.py index 0112b8b2..0fcf4624 100644 --- a/EpikCord/guild.py +++ b/EpikCord/guild.py @@ -37,15 +37,25 @@ def __init__(self, client: Client, data: GuildMemberData): datetime.fromisoformat, data.get("communication_disabled_until") ) + class RoleTags: def __init__(self, data: RoleTagsData): self._data = data self.bot_id = int_or_none(data.get("bot_id")) self.integration_id = int_or_none(data.get("integration_id")) - self.premium_subscriber = True if data.get("premium_subscriber") else False - self.subscription_listing_id = int_or_none(data.get("subscription_listing_id")) - self.available_for_purchase = True if data.get("available_for_purchase") else False - self.guild_connections = True if data.get("guild_connections") else False + self.premium_subscriber = ( + True if data.get("premium_subscriber") else False + ) + self.subscription_listing_id = int_or_none( + data.get("subscription_listing_id") + ) + self.available_for_purchase = ( + True if data.get("available_for_purchase") else False + ) + self.guild_connections = ( + True if data.get("guild_connections") else False + ) + class Role: def __init__(self, client: Client, data: RoleData): @@ -61,4 +71,4 @@ def __init__(self, client: Client, data: RoleData): self.permissions = Permissions(int(data["permissions"])) self.managed = data["managed"] self.mentionable = data["mentionable"] - self.tags = instance_or_none(RoleTags, data.get("tags")) \ No newline at end of file + self.tags = instance_or_none(RoleTags, data.get("tags")) diff --git a/EpikCord/interaction.py b/EpikCord/interaction.py index 69854698..51be39f0 100644 --- a/EpikCord/interaction.py +++ b/EpikCord/interaction.py @@ -1,11 +1,22 @@ -from discord_typings import ApplicationCommandInteractionData, InteractionData, ResolvedInteractionDataData +from discord_typings import ( + ApplicationCommandInteractionData, + InteractionData, + ResolvedInteractionDataData, +) from .client import Client from .file import Attachment from .flags import Permissions from .guild import GuildMember, Role from .user import User -from .utils import InteractionType, Locale, instance_or_none, int_or_none, ApplicationCommandType +from .utils import ( + InteractionType, + Locale, + instance_or_none, + int_or_none, + ApplicationCommandType, +) + class BaseInteraction: def __init__(self, client: Client, data: InteractionData): @@ -33,14 +44,25 @@ def __init__(self, client: Client, data: InteractionData): self.locale = instance_or_none(Locale, data.get("locale")) self.guild_locale = instance_or_none(Locale, data.get("guild_locale")) + class ResolvedInteractionData: def __init__(self, client: Client, data: ResolvedInteractionDataData): - self.users = {int(k): User(client, v) for k, v in data.get("users", {}).items()} - self.members = {int(k): GuildMember(client, v) for k, v in data.get("members", {}).items()} - self.roles = {int(k): Role(client, v) for k, v in data.get("roles", {}).items()} + self.users = { + int(k): User(client, v) for k, v in data.get("users", {}).items() + } + self.members = { + int(k): GuildMember(client, v) + for k, v in data.get("members", {}).items() + } + self.roles = { + int(k): Role(client, v) for k, v in data.get("roles", {}).items() + } # CHANNELS # MESSAGES - self.attachments = {int(k): Attachment(v) for k, v in data.get("attachments", {}).items()} + self.attachments = { + int(k): Attachment(v) + for k, v in data.get("attachments", {}).items() + } class BaseApplicationCommandInteraction(BaseInteraction): @@ -49,12 +71,17 @@ def __init__(self, client: Client, data: ApplicationCommandInteractionData): self.data = data["data"] self.command_id = int(self.data["id"]) self.command_name = self.data["name"] - self.command_type: ApplicationCommandType = ApplicationCommandType(self.data["type"]) - self.resolved = instance_or_none(ResolvedInteractionData, data.get("resolved")) + self.command_type: ApplicationCommandType = ApplicationCommandType( + self.data["type"] + ) + self.resolved = instance_or_none( + ResolvedInteractionData, data.get("resolved") + ) + # class ChatInputCommandInteraction(): # def __init__(self, client: Client, data: ApplicationCommandInteractionData): # super().__init__(client, data) # # OPTIONS -# class ContextMenuInteraction(BaseInteraction): \ No newline at end of file +# class ContextMenuInteraction(BaseInteraction): diff --git a/EpikCord/locales.py b/EpikCord/locales.py new file mode 100644 index 00000000..1c387ddb --- /dev/null +++ b/EpikCord/locales.py @@ -0,0 +1,12 @@ +from typing import Dict +from .utils import Locale + + +class Localization: + def __init__(self, locale: Locale, value: str): + + self.locale = locale + self.value = value + + def to_dict(self) -> Dict[str, str]: + return {self.locale.value: self.value} diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py index 72f93922..1666d5ea 100644 --- a/EpikCord/utils/__init__.py +++ b/EpikCord/utils/__init__.py @@ -10,7 +10,7 @@ TeamMembershipState, VoiceCloseCode, VoiceOpCode, - ApplicationCommandType + ApplicationCommandType, ) from .loose import ( add_file, @@ -24,6 +24,7 @@ json_serialize, log_request, singleton, + localization_list_to_dict ) from .types import ( AsyncFunction, @@ -60,4 +61,5 @@ "singleton", "instance_or_none", "int_or_none", + "localization_list_to_dict" ) diff --git a/EpikCord/utils/enums.py b/EpikCord/utils/enums.py index 36d558d0..5f142f54 100644 --- a/EpikCord/utils/enums.py +++ b/EpikCord/utils/enums.py @@ -401,7 +401,8 @@ class InteractionType(IntEnum): APPLICATION_COMMAND_AUTOCOMPLETE = 4 MODAL_SUBMIT = 5 + class ApplicationCommandType(IntEnum): CHAT_INPUT = 1 USER = 2 - MESSAGE = 3 \ No newline at end of file + MESSAGE = 3 diff --git a/EpikCord/utils/loose.py b/EpikCord/utils/loose.py index 78e7d92d..70addb0f 100644 --- a/EpikCord/utils/loose.py +++ b/EpikCord/utils/loose.py @@ -10,6 +10,7 @@ Any, Callable, Dict, + List, Optional, Type, TypeVar, @@ -18,7 +19,9 @@ import aiohttp + from ..file import File +from ..locales import Localization T = TypeVar("T") @@ -163,3 +166,12 @@ def instance_or_none( int_or_none = partial(instance_or_none, cls=int) + + +def localization_list_to_dict( + localizations: List[Localization] +) -> Dict[str, str]: + payload = {} + for localization in localizations: + payload[localization.locale.value] = localization.value + return payload \ No newline at end of file diff --git a/tests/unit/test_clean_url.py b/tests/unit/test_clean_url.py index 96999317..82df23b2 100644 --- a/tests/unit/test_clean_url.py +++ b/tests/unit/test_clean_url.py @@ -4,30 +4,26 @@ def test_clean_url(): url = "channels/1234567890/messages/1234567890" assert clean_url(url, 9) == ( - 'https://discord.com/api/v9/channels/' - '1234567890/messages/1234567890' + "https://discord.com/api/v9/channels/" "1234567890/messages/1234567890" ) def test_clean_url_with_slash(): url = "/channels/1234567890/messages/1234567890" assert clean_url(url, 9) == ( - 'https://discord.com/api/v9/channels/' - '1234567890/messages/1234567890' + "https://discord.com/api/v9/channels/" "1234567890/messages/1234567890" ) def test_clean_url_with_version(): url = "channels/1234567890/messages/1234567890" assert clean_url(url, 8) == ( - 'https://discord.com/api/v8/channels/' - '1234567890/messages/1234567890' + "https://discord.com/api/v8/channels/" "1234567890/messages/1234567890" ) def test_clean_url_with_double_slash(): url = "/channels/1234567890/messages/1234567890/" assert clean_url(url, 9) == ( - 'https://discord.com/api/v9/channels/' - '1234567890/messages/1234567890' + "https://discord.com/api/v9/channels/" "1234567890/messages/1234567890" ) diff --git a/tests/unit/test_instance_or_none.py b/tests/unit/test_instance_or_none.py index edf2ff45..da6f18d1 100644 --- a/tests/unit/test_instance_or_none.py +++ b/tests/unit/test_instance_or_none.py @@ -2,7 +2,6 @@ class Test: - def __init__(self, value: int): self.value = value diff --git a/tests/unit/test_json_serialize.py b/tests/unit/test_json_serialize.py index 2589a51e..98127ecb 100644 --- a/tests/unit/test_json_serialize.py +++ b/tests/unit/test_json_serialize.py @@ -4,7 +4,7 @@ def test_json_serialize(): data = {"a": 1, "b": 2} - assert json_serialize(data).replace(' ', '') == '{"a":1,"b":2}' + assert json_serialize(data).replace(" ", "") == '{"a":1,"b":2}' def test_json_serialize_empty(): @@ -33,7 +33,7 @@ def test_json_serialize_complex(): }, } - assert json_serialize(data).replace(' ', '') == ( + assert json_serialize(data).replace(" ", "") == ( '{"op":2,"d":{"token":"token","intents":1,"properties":{"os":"tests",' '"browser":"EpikCord.py","device":"EpikCord.py"},"compress":true,' '"large_threshold":50}}' diff --git a/tests/unit/test_singleton.py b/tests/unit/test_singleton.py index b5f3f2d7..69ae450c 100644 --- a/tests/unit/test_singleton.py +++ b/tests/unit/test_singleton.py @@ -5,7 +5,6 @@ class TestClass: - def __init__(self): self.time = random.random() From 0383817c5e178fe8febb97c1f0a4e8b0f58c1863 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Fri, 17 Feb 2023 11:42:23 +0000 Subject: [PATCH 222/335] Fix an issue --- EpikCord/utils/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py index 7e7948c2..b0d0e22a 100644 --- a/EpikCord/utils/__init__.py +++ b/EpikCord/utils/__init__.py @@ -11,10 +11,7 @@ TeamMembershipState, VoiceCloseCode, VoiceOpCode, -<<<<<<< HEAD ApplicationCommandType, -======= ->>>>>>> fde3de68b3b6e94f83f1525f47a68fbd2fc3bee5 ) from .loose import ( add_file, From 5f1ce37b9fcd3b3e21ac4c79279367c635f8fa20 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 17 Feb 2023 11:43:50 +0000 Subject: [PATCH 223/335] :art: Format code automatically --- EpikCord/client/commands.py | 20 ++++++++++++++------ EpikCord/locales.py | 2 +- EpikCord/utils/__init__.py | 5 ++--- EpikCord/utils/loose.py | 5 ++--- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/EpikCord/client/commands.py b/EpikCord/client/commands.py index 346a1bec..bddbc48e 100644 --- a/EpikCord/client/commands.py +++ b/EpikCord/client/commands.py @@ -2,7 +2,11 @@ from ..flags import Permissions from ..locales import Localization -from ..utils import AsyncFunction, ApplicationCommandType, localization_list_to_dict +from ..utils import ( + ApplicationCommandType, + AsyncFunction, + localization_list_to_dict, +) class BaseClientCommand: @@ -32,7 +36,6 @@ def __init__( self.nsfw = nsfw def to_dict(self): - payload = { "name": self.name, # "description": self.description, @@ -43,15 +46,20 @@ def to_dict(self): if self.guild_ids: payload["guild_ids"] = self.guild_ids if self.default_member_permissions: - payload["default_member_permissions"] = self.default_member_permissions.value + payload[ + "default_member_permissions" + ] = self.default_member_permissions.value if self.guild_only is not None: payload["dm_permission"] = not self.guild_only - if name_localizations := localization_list_to_dict(self.name_localizations): + if name_localizations := localization_list_to_dict( + self.name_localizations + ): payload["name_localizations"] = name_localizations # if description_localizations := localization_list_to_dict(self.description_localizations): # payload["description_localizations"] = description_localizations - return payload + return payload + class ClientContextMenuCommand(BaseClientCommand): - ... \ No newline at end of file + ... diff --git a/EpikCord/locales.py b/EpikCord/locales.py index 1c387ddb..d5e5fb80 100644 --- a/EpikCord/locales.py +++ b/EpikCord/locales.py @@ -1,10 +1,10 @@ from typing import Dict + from .utils import Locale class Localization: def __init__(self, locale: Locale, value: str): - self.locale = locale self.value = value diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py index b0d0e22a..465b07c2 100644 --- a/EpikCord/utils/__init__.py +++ b/EpikCord/utils/__init__.py @@ -11,7 +11,6 @@ TeamMembershipState, VoiceCloseCode, VoiceOpCode, - ApplicationCommandType, ) from .loose import ( add_file, @@ -23,9 +22,9 @@ instance_or_none, int_or_none, json_serialize, + localization_list_to_dict, log_request, singleton, - localization_list_to_dict ) from .types import ( AsyncFunction, @@ -62,5 +61,5 @@ "singleton", "instance_or_none", "int_or_none", - "localization_list_to_dict" + "localization_list_to_dict", ) diff --git a/EpikCord/utils/loose.py b/EpikCord/utils/loose.py index 70addb0f..5c16b460 100644 --- a/EpikCord/utils/loose.py +++ b/EpikCord/utils/loose.py @@ -19,7 +19,6 @@ import aiohttp - from ..file import File from ..locales import Localization @@ -169,9 +168,9 @@ def instance_or_none( def localization_list_to_dict( - localizations: List[Localization] + localizations: List[Localization], ) -> Dict[str, str]: payload = {} for localization in localizations: payload[localization.locale.value] = localization.value - return payload \ No newline at end of file + return payload From 6372f89c71e9295e6fdc7fed0e33b195ef45e332 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Fri, 17 Feb 2023 11:46:24 +0000 Subject: [PATCH 224/335] Condense localization_list_to_dict Co-authored-by: Sigmanificient --- EpikCord/utils/loose.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/EpikCord/utils/loose.py b/EpikCord/utils/loose.py index 5c16b460..a6055656 100644 --- a/EpikCord/utils/loose.py +++ b/EpikCord/utils/loose.py @@ -170,7 +170,8 @@ def instance_or_none( def localization_list_to_dict( localizations: List[Localization], ) -> Dict[str, str]: - payload = {} - for localization in localizations: - payload[localization.locale.value] = localization.value + payload = { + localization.locale.value: localization.value + for localization in localizations + } return payload From 299e8e50779f8c89e8fbd5ab5e366259c7e416ee Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Fri, 17 Feb 2023 11:46:52 +0000 Subject: [PATCH 225/335] Condense localization_list_to_dict Co-authored-by: Sigmanificient --- EpikCord/utils/loose.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/EpikCord/utils/loose.py b/EpikCord/utils/loose.py index a6055656..faa1f69d 100644 --- a/EpikCord/utils/loose.py +++ b/EpikCord/utils/loose.py @@ -170,8 +170,7 @@ def instance_or_none( def localization_list_to_dict( localizations: List[Localization], ) -> Dict[str, str]: - payload = { + return { localization.locale.value: localization.value for localization in localizations } - return payload From 461c41411303d2e013fd279756d2435d52e232c2 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Fri, 17 Feb 2023 13:01:11 +0000 Subject: [PATCH 226/335] Solve circular imports --- EpikCord/file.py | 1 - 1 file changed, 1 deletion(-) diff --git a/EpikCord/file.py b/EpikCord/file.py index 6d928c38..4c8ae83d 100644 --- a/EpikCord/file.py +++ b/EpikCord/file.py @@ -4,7 +4,6 @@ from discord_typings import AttachmentData -from .client import Client from .exceptions import UnknownMimeType From 5ac38ad89f470038dc9482892d1333ade4340f64 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Fri, 17 Feb 2023 13:58:57 +0000 Subject: [PATCH 227/335] Add TokenStore.remove_from utilit --- EpikCord/client/client.py | 3 +++ EpikCord/client/websocket.py | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index f41aa153..23801989 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -13,6 +13,9 @@ class TokenStore: def __init__(self, token: str): self.value: str = token + def remove_from(self, string: str) -> str: + return string.replace(self.value, "[REMOVED TOKEN]") + class Client(WebSocketClient): """ diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 1ef4e14c..6d544c65 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -122,6 +122,9 @@ async def send_json(self, payload: Any): return await self.client.rate_limiter.tick() logger.debug("Sending %s to Gateway", payload) + for k, v in payload.items(): + if isinstance(v, OpCode): + payload[k] = v.value await self.client.ws.send_json(payload) async def _ready(self, data: ReadyData): @@ -370,6 +373,8 @@ async def handle_close(self): close_code = self.ws.close_code + logger.critical("Websocket closed with code %s", close_code) + try: gce_code = GatewayCloseCode(close_code) ch_ins = close_dispatcher[gce_code] From 3bd118e2d708de88876bfee9ca88656b854f2a3e Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Fri, 17 Feb 2023 21:51:01 +0000 Subject: [PATCH 228/335] Debug what URL is being ws_connect'd to --- EpikCord/client/http.py | 1 + EpikCord/client/websocket.py | 1 + 2 files changed, 2 insertions(+) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index f2dfd9de..367b732e 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -162,6 +162,7 @@ def __init__( self.global_event.set() async def ws_connect(self, url: str, **kwargs) -> GatewayWebSocket: + logger.debug("Connecting websocket to %s", url) return await self.session.ws_connect(url, **kwargs) # type: ignore async def request( diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 6d544c65..1eec5008 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -253,6 +253,7 @@ async def handle(self, message: DiscordWSMessage): else: event_name = event["t"].lower() event_data = event["d"] + self.client.sequence = event["s"] if event_handler := getattr(self, f"_{event_name}", None): asyncio.create_task(event_handler(event_data)) From 953d2a5783bfce167614bb84aacfd9008383dd8d Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sat, 18 Feb 2023 19:25:41 +0000 Subject: [PATCH 229/335] Create option classes --- EpikCord/client/commands.py | 465 +++++++++++++++++++++++++++++++++++- EpikCord/utils/__init__.py | 4 + EpikCord/utils/enums.py | 27 +++ 3 files changed, 484 insertions(+), 12 deletions(-) diff --git a/EpikCord/client/commands.py b/EpikCord/client/commands.py index bddbc48e..a7bd9e33 100644 --- a/EpikCord/client/commands.py +++ b/EpikCord/client/commands.py @@ -1,11 +1,13 @@ -from typing import List, Optional +from typing import Dict, List, Optional, Union from ..flags import Permissions from ..locales import Localization from ..utils import ( ApplicationCommandType, + ApplicationCommandOptionType, AsyncFunction, localization_list_to_dict, + ChannelType, ) @@ -13,34 +15,35 @@ class BaseClientCommand: def __init__( self, name: str, - # description: str, callback: AsyncFunction, *, guild_ids: List[int] = [], name_localizations: List[Localization] = [], - # description_localizations: List[Localization] = [], type: ApplicationCommandType = ApplicationCommandType.CHAT_INPUT, - guild_only: bool = False, + guild_only: Optional[bool] = None, default_member_permissions: Optional[Permissions] = None, nsfw: bool = False, ): self.name = name - # self.description = description self.guild_ids = guild_ids self.callback = callback self.name_localizations = name_localizations - # self.description_localizations = description_localizations self.type = type - self.guild_only = guild_only + self.guild_only = guild_only if guild_only is not None else False if not self.guild_ids else True self.default_member_permissions = default_member_permissions self.nsfw = nsfw + if self.guild_ids and not self.guild_only: + raise ValueError( + "Guild IDs cannot be set if the command is not set to guild only." + ) + def to_dict(self): payload = { "name": self.name, - # "description": self.description, "type": self.type.value, "nsfw": self.nsfw, + "dm_permission": not self.guild_only } if self.guild_ids: @@ -49,17 +52,455 @@ def to_dict(self): payload[ "default_member_permissions" ] = self.default_member_permissions.value - if self.guild_only is not None: - payload["dm_permission"] = not self.guild_only if name_localizations := localization_list_to_dict( self.name_localizations ): payload["name_localizations"] = name_localizations - # if description_localizations := localization_list_to_dict(self.description_localizations): - # payload["description_localizations"] = description_localizations return payload class ClientContextMenuCommand(BaseClientCommand): ... + +class ClientUserCommand(ClientContextMenuCommand): + def __init__( + self, + name: str, + callback: AsyncFunction, + *, + guild_ids: List[int] = [], + name_localizations: List[Localization] = [], + guild_only: Optional[bool] = None, + default_member_permissions: Optional[Permissions] = None, + nsfw: bool = False, + ): + super().__init__( + name, + callback, + guild_ids=guild_ids, + name_localizations=name_localizations, + type=ApplicationCommandType.USER, + guild_only=guild_only, + default_member_permissions=default_member_permissions, + nsfw=nsfw, + ) + +class ClientMessageCommand(ClientContextMenuCommand): + def __init__( + self, + name: str, + callback: AsyncFunction, + *, + guild_ids: List[int] = [], + name_localizations: List[Localization] = [], + guild_only: Optional[bool] = None, + default_member_permissions: Optional[Permissions] = None, + nsfw: bool = False, + ): + super().__init__( + name, + callback, + guild_ids=guild_ids, + name_localizations=name_localizations, + type=ApplicationCommandType.MESSAGE, + guild_only=guild_only, + default_member_permissions=default_member_permissions, + nsfw=nsfw, + ) + +class ApplicationCommandOptionChoice: + def __init__(self, name: str, value: str, *, name_localizations: List[Localization] = []): + self.name = name + self.value = value + self.name_localizations = name_localizations + + def to_dict(self): + payload: Dict[str, Union[str, Dict[str, str]]] = { + "name": self.name, + "value": self.value, + } + + if name_localizations := localization_list_to_dict( + self.name_localizations + ): + payload["name_localizations"] = name_localizations + + return payload + + +class BaseApplicationCommandOption: + def __init__( + self, + name: str, + description: str, + *, + name_localizations: List[Localization] = [], + description_localizations: List[Localization] = [], + required: bool = False, + type: ApplicationCommandOptionType, + ): + self.name = name + self.description = description + self.name_localizations = name_localizations + self.description_localizations = description_localizations + self.required = required + self.type = type + + def to_dict(self): + payload = { + "name": self.name, + "description": self.description, + "required": self.required, + "type": self.type.value, + } + + if name_localizations := localization_list_to_dict( + self.name_localizations + ): + payload["name_localizations"] = name_localizations + + if description_localizations := localization_list_to_dict( + self.description_localizations + ): + payload["description_localizations"] = description_localizations + + return payload + +class BaseStringNumberOption(BaseApplicationCommandOption): + def __init__( + self, + name: str, + description: str, + *, + name_localizations: List[Localization] = [], + description_localizations: List[Localization] = [], + required: bool = False, + type: ApplicationCommandOptionType, + choices: List[ApplicationCommandOptionChoice] = [], + autocomplete: bool = False + ): + super().__init__( + name, + description, + name_localizations=name_localizations, + description_localizations=description_localizations, + required=required, + type=type, + ) + self.choices = choices + self.autocomplete = autocomplete + + def to_dict(self): + payload = super().to_dict() + if self.choices: + payload["choices"] = [choice.to_dict() for choice in self.choices] + if self.autocomplete: + payload["autocomplete"] = True + return payload + +class BaseIntegerNumberOption(BaseStringNumberOption): + def __init__( + self, + name: str, + description: str, + *, + name_localizations: List[Localization] = [], + description_localizations: List[Localization] = [], + required: bool = False, + type: ApplicationCommandOptionType, + choices: List[ApplicationCommandOptionChoice] = [], + autocomplete: bool = False, + min_value: Optional[int] = None, + max_value: Optional[int] = None, + ): + super().__init__( + name, + description, + name_localizations=name_localizations, + description_localizations=description_localizations, + required=required, + type=type, + choices=choices, + autocomplete=autocomplete, + ) + self.min_value = min_value + self.max_value = max_value + + def to_dict(self): + payload = super().to_dict() + if self.min_value: + payload["min_value"] = self.min_value + if self.max_value: + payload["max_value"] = self.max_value + return payload + +class IntegerOption(BaseIntegerNumberOption): + def __init__( + self, + name: str, + description: str, + *, + name_localizations: List[Localization] = [], + description_localizations: List[Localization] = [], + required: bool = False, + choices: List[ApplicationCommandOptionChoice] = [], + autocomplete: bool = False, + min_value: Optional[int] = None, + max_value: Optional[int] = None, + ): + super().__init__( + name, + description, + name_localizations=name_localizations, + description_localizations=description_localizations, + required=required, + type=ApplicationCommandOptionType.INTEGER, + choices=choices, + autocomplete=autocomplete, + min_value=min_value, + max_value=max_value, + ) + +class NumberOption(BaseIntegerNumberOption): + def __init__( + self, + name: str, + description: str, + *, + name_localizations: List[Localization] = [], + description_localizations: List[Localization] = [], + required: bool = False, + choices: List[ApplicationCommandOptionChoice] = [], + autocomplete: bool = False, + min_value: Optional[int] = None, + max_value: Optional[int] = None, + ): + super().__init__( + name, + description, + name_localizations=name_localizations, + description_localizations=description_localizations, + required=required, + type=ApplicationCommandOptionType.NUMBER, + choices=choices, + autocomplete=autocomplete, + min_value=min_value, + max_value=max_value, + ) + +class StringOption(BaseStringNumberOption): + def __init__( + self, + name: str, + description: str, + *, + name_localizations: List[Localization] = [], + description_localizations: List[Localization] = [], + required: bool = False, + choices: List[ApplicationCommandOptionChoice] = [], + autocomplete: bool = False, + min_length: Optional[int] = None, + max_length: Optional[int] = None, + ): + super().__init__( + name, + description, + name_localizations=name_localizations, + description_localizations=description_localizations, + required=required, + type=ApplicationCommandOptionType.STRING, + choices=choices, + autocomplete=autocomplete, + ) + self.min_length = min_length + self.max_length = max_length + + def to_dict(self): + payload = super().to_dict() + if self.min_length: + payload["min_length"] = self.min_length + if self.max_length: + payload["max_length"] = self.max_length + return payload + + +class BooleanOption(BaseApplicationCommandOption): + def __init__( + self, + name: str, + description: str, + *, + name_localizations: List[Localization] = [], + description_localizations: List[Localization] = [], + required: bool = False, + ): + super().__init__( + name, + description, + name_localizations=name_localizations, + description_localizations=description_localizations, + required=required, + type=ApplicationCommandOptionType.BOOLEAN, + ) + +class UserOption(BaseApplicationCommandOption): + def __init__( + self, + name: str, + description: str, + *, + name_localizations: List[Localization] = [], + description_localizations: List[Localization] = [], + required: bool = False, + ): + super().__init__( + name, + description, + name_localizations=name_localizations, + description_localizations=description_localizations, + required=required, + type=ApplicationCommandOptionType.USER, + ) + +class RoleOption(BaseApplicationCommandOption): + def __init__( + self, + name: str, + description: str, + *, + name_localizations: List[Localization] = [], + description_localizations: List[Localization] = [], + required: bool = False, + ): + super().__init__( + name, + description, + name_localizations=name_localizations, + description_localizations=description_localizations, + required=required, + type=ApplicationCommandOptionType.ROLE, + ) + +class AttachmentOption(BaseApplicationCommandOption): + def __init__( + self, + name: str, + description: str, + *, + name_localizations: List[Localization] = [], + description_localizations: List[Localization] = [], + required: bool = False, + ): + super().__init__( + name, + description, + name_localizations=name_localizations, + description_localizations=description_localizations, + required=required, + type=ApplicationCommandOptionType.ATTACHMENT, + ) + +class MentionableOption(BaseApplicationCommandOption): + def __init__( + self, + name: str, + description: str, + *, + name_localizations: List[Localization] = [], + description_localizations: List[Localization] = [], + required: bool = False, + ): + super().__init__( + name, + description, + name_localizations=name_localizations, + description_localizations=description_localizations, + required=required, + type=ApplicationCommandOptionType.MENTIONABLE, + ) + +class ChannelOption(BaseApplicationCommandOption): + def __init__( + self, + name: str, + description: str, + *, + name_localizations: List[Localization] = [], + description_localizations: List[Localization] = [], + required: bool = False, + channel_types: Optional[List[ChannelType]] + ): + super().__init__( + name, + description, + name_localizations=name_localizations, + description_localizations=description_localizations, + required=required, + type=ApplicationCommandOptionType.CHANNEL, + ) + self.channel_types = channel_types + + def to_dict(self): + payload = super().to_dict() + if self.channel_types: + payload["channel_types"] = self.channel_types + return payload + +ApplicationCommandOption = Union[ + IntegerOption, + NumberOption, + StringOption, + BooleanOption, + UserOption, + RoleOption, + AttachmentOption, + MentionableOption, + ChannelOption, +] + + +class ClientChatInputCommand(BaseClientCommand): + def __init__( + self, + name: str, + description: str, + callback: AsyncFunction, + *, + guild_ids: List[int] = [], + name_localizations: List[Localization] = [], + description_localizations: List[Localization] = [], + type: ApplicationCommandType = ApplicationCommandType.CHAT_INPUT, + guild_only: Optional[bool] = None, + default_member_permissions: Optional[Permissions] = None, + nsfw: bool = False, + options: Optional[List[str]] = None, + ): + super().__init__( + name, + callback, + guild_ids=guild_ids, + name_localizations=name_localizations, + type=type, + guild_only=guild_only, + default_member_permissions=default_member_permissions, + nsfw=nsfw, + ) + self.description = description + self.description_localizations = description_localizations + self.options = options + + def to_dict(self): + + payload = super().to_dict() + + payload["description"] = self.description + + if description_localizations := localization_list_to_dict( + self.description_localizations + ): + payload["description_localizations"] = description_localizations + if self.options: + payload["options"] = [option.to_dict() for option in self.options] # type: ignore # I still need to create the Option class + + return payload \ No newline at end of file diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py index 465b07c2..5ce10603 100644 --- a/EpikCord/utils/__init__.py +++ b/EpikCord/utils/__init__.py @@ -11,6 +11,8 @@ TeamMembershipState, VoiceCloseCode, VoiceOpCode, + ApplicationCommandOptionType, + ChannelType ) from .loose import ( add_file, @@ -48,6 +50,7 @@ "InteractionType", "ApplicationCommandType", "TeamMembershipState", + "ApplicationCommandOptionType", "Locale", "PremiumType", "add_file", @@ -59,6 +62,7 @@ "json_serialize", "log_request", "singleton", + "ChannelType", "instance_or_none", "int_or_none", "localization_list_to_dict", diff --git a/EpikCord/utils/enums.py b/EpikCord/utils/enums.py index 5f142f54..97aca92b 100644 --- a/EpikCord/utils/enums.py +++ b/EpikCord/utils/enums.py @@ -406,3 +406,30 @@ class ApplicationCommandType(IntEnum): CHAT_INPUT = 1 USER = 2 MESSAGE = 3 + +class ApplicationCommandOptionType(IntEnum): + SUB_COMMAND = 1 + SUB_COMMAND_GROUP = 2 + STRING = 3 + INTEGER = 4 + BOOLEAN = 5 + USER = 6 + CHANNEL = 7 + ROLE = 8 + MENTIONABLE = 9 + NUMBER = 10 + ATTACHMENT = 11 + +class ChannelType(IntEnum): + GUILD_TEXT = 0 + DM = 1 + GUILD_VOICE = 2 + GROUP_DM = 3 + GUILD_CATEGORY = 4 + GUILD_ANNOUNCEMENT = 5 + ANNOUNCEMENT_THREAD = 10 + PUBLIC_THREAD = 11 + PRIVATE_THREAD = 12 + GUILD_STAGE_VOICE = 13 + GUILD_DIRECTORY = 14 + GUILD_FORUM = 15 \ No newline at end of file From 4f06e3fceb7bc2c07cadc8414005844974effb7b Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Mon, 20 Feb 2023 20:39:52 +0000 Subject: [PATCH 230/335] Current state --- EpikCord/client/client.py | 5 +++++ EpikCord/interaction.py | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index 23801989..cce44934 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -1,6 +1,8 @@ import asyncio from typing import Optional +from EpikCord.utils.types import AsyncFunction + from ..flags import Intents from ..presence import Presence from ..utils import OpCode, cleanup_loop, singleton @@ -96,3 +98,6 @@ async def change_presence(self, presence: Presence): await self.ws.send_json( {"op": OpCode.PRESENCE_UPDATE, "d": presence.to_dict()} ) + + def command(self, function: AsyncFunction): # TODO: Add in types for command callbacks + ... diff --git a/EpikCord/interaction.py b/EpikCord/interaction.py index cdef8674..b7f6d6bd 100644 --- a/EpikCord/interaction.py +++ b/EpikCord/interaction.py @@ -1,6 +1,8 @@ from discord_typings import ( ApplicationCommandInteractionData, InteractionData, + ChatInputCommandInteractionDataData, + ContextMenuInteractionDataData, ResolvedInteractionDataData, ) @@ -84,4 +86,7 @@ def __init__(self, client: Client, data: ApplicationCommandInteractionData): # super().__init__(client, data) # # OPTIONS -# class ContextMenuInteraction(BaseInteraction): +class BaseContextMenuInteraction(BaseApplicationCommandInteraction): + def __init__(self, client: Client, data): + super().__init__(client, data) + self.target_id = int(self.data["target_id"]) \ No newline at end of file From 83b81c148bf653fb881e7e53e1e007f2d31a684a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 20 Feb 2023 20:40:41 +0000 Subject: [PATCH 231/335] :art: Format code automatically --- EpikCord/client/client.py | 4 +++- EpikCord/client/commands.py | 45 ++++++++++++++++++++++++++++--------- EpikCord/interaction.py | 5 +++-- EpikCord/utils/__init__.py | 4 ++-- EpikCord/utils/enums.py | 8 ++++--- 5 files changed, 48 insertions(+), 18 deletions(-) diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index cce44934..9eee4c59 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -99,5 +99,7 @@ async def change_presence(self, presence: Presence): {"op": OpCode.PRESENCE_UPDATE, "d": presence.to_dict()} ) - def command(self, function: AsyncFunction): # TODO: Add in types for command callbacks + def command( + self, function: AsyncFunction + ): # TODO: Add in types for command callbacks ... diff --git a/EpikCord/client/commands.py b/EpikCord/client/commands.py index a7bd9e33..c9977b18 100644 --- a/EpikCord/client/commands.py +++ b/EpikCord/client/commands.py @@ -3,11 +3,11 @@ from ..flags import Permissions from ..locales import Localization from ..utils import ( - ApplicationCommandType, ApplicationCommandOptionType, + ApplicationCommandType, AsyncFunction, - localization_list_to_dict, ChannelType, + localization_list_to_dict, ) @@ -29,7 +29,13 @@ def __init__( self.callback = callback self.name_localizations = name_localizations self.type = type - self.guild_only = guild_only if guild_only is not None else False if not self.guild_ids else True + self.guild_only = ( + guild_only + if guild_only is not None + else False + if not self.guild_ids + else True + ) self.default_member_permissions = default_member_permissions self.nsfw = nsfw @@ -43,7 +49,7 @@ def to_dict(self): "name": self.name, "type": self.type.value, "nsfw": self.nsfw, - "dm_permission": not self.guild_only + "dm_permission": not self.guild_only, } if self.guild_ids: @@ -63,6 +69,7 @@ def to_dict(self): class ClientContextMenuCommand(BaseClientCommand): ... + class ClientUserCommand(ClientContextMenuCommand): def __init__( self, @@ -86,6 +93,7 @@ def __init__( nsfw=nsfw, ) + class ClientMessageCommand(ClientContextMenuCommand): def __init__( self, @@ -109,8 +117,15 @@ def __init__( nsfw=nsfw, ) + class ApplicationCommandOptionChoice: - def __init__(self, name: str, value: str, *, name_localizations: List[Localization] = []): + def __init__( + self, + name: str, + value: str, + *, + name_localizations: List[Localization] = [], + ): self.name = name self.value = value self.name_localizations = name_localizations @@ -167,6 +182,7 @@ def to_dict(self): return payload + class BaseStringNumberOption(BaseApplicationCommandOption): def __init__( self, @@ -178,7 +194,7 @@ def __init__( required: bool = False, type: ApplicationCommandOptionType, choices: List[ApplicationCommandOptionChoice] = [], - autocomplete: bool = False + autocomplete: bool = False, ): super().__init__( name, @@ -199,6 +215,7 @@ def to_dict(self): payload["autocomplete"] = True return payload + class BaseIntegerNumberOption(BaseStringNumberOption): def __init__( self, @@ -235,6 +252,7 @@ def to_dict(self): payload["max_value"] = self.max_value return payload + class IntegerOption(BaseIntegerNumberOption): def __init__( self, @@ -262,6 +280,7 @@ def __init__( max_value=max_value, ) + class NumberOption(BaseIntegerNumberOption): def __init__( self, @@ -289,6 +308,7 @@ def __init__( max_value=max_value, ) + class StringOption(BaseStringNumberOption): def __init__( self, @@ -344,6 +364,7 @@ def __init__( type=ApplicationCommandOptionType.BOOLEAN, ) + class UserOption(BaseApplicationCommandOption): def __init__( self, @@ -363,6 +384,7 @@ def __init__( type=ApplicationCommandOptionType.USER, ) + class RoleOption(BaseApplicationCommandOption): def __init__( self, @@ -382,6 +404,7 @@ def __init__( type=ApplicationCommandOptionType.ROLE, ) + class AttachmentOption(BaseApplicationCommandOption): def __init__( self, @@ -401,6 +424,7 @@ def __init__( type=ApplicationCommandOptionType.ATTACHMENT, ) + class MentionableOption(BaseApplicationCommandOption): def __init__( self, @@ -420,6 +444,7 @@ def __init__( type=ApplicationCommandOptionType.MENTIONABLE, ) + class ChannelOption(BaseApplicationCommandOption): def __init__( self, @@ -429,7 +454,7 @@ def __init__( name_localizations: List[Localization] = [], description_localizations: List[Localization] = [], required: bool = False, - channel_types: Optional[List[ChannelType]] + channel_types: Optional[List[ChannelType]], ): super().__init__( name, @@ -447,6 +472,7 @@ def to_dict(self): payload["channel_types"] = self.channel_types return payload + ApplicationCommandOption = Union[ IntegerOption, NumberOption, @@ -491,7 +517,6 @@ def __init__( self.options = options def to_dict(self): - payload = super().to_dict() payload["description"] = self.description @@ -501,6 +526,6 @@ def to_dict(self): ): payload["description_localizations"] = description_localizations if self.options: - payload["options"] = [option.to_dict() for option in self.options] # type: ignore # I still need to create the Option class + payload["options"] = [option.to_dict() for option in self.options] # type: ignore # I still need to create the Option class - return payload \ No newline at end of file + return payload diff --git a/EpikCord/interaction.py b/EpikCord/interaction.py index b7f6d6bd..1fd23838 100644 --- a/EpikCord/interaction.py +++ b/EpikCord/interaction.py @@ -1,8 +1,8 @@ from discord_typings import ( ApplicationCommandInteractionData, - InteractionData, ChatInputCommandInteractionDataData, ContextMenuInteractionDataData, + InteractionData, ResolvedInteractionDataData, ) @@ -86,7 +86,8 @@ def __init__(self, client: Client, data: ApplicationCommandInteractionData): # super().__init__(client, data) # # OPTIONS + class BaseContextMenuInteraction(BaseApplicationCommandInteraction): def __init__(self, client: Client, data): super().__init__(client, data) - self.target_id = int(self.data["target_id"]) \ No newline at end of file + self.target_id = int(self.data["target_id"]) diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py index 5ce10603..491fa2c1 100644 --- a/EpikCord/utils/__init__.py +++ b/EpikCord/utils/__init__.py @@ -1,5 +1,7 @@ from .enums import ( + ApplicationCommandOptionType, ApplicationCommandType, + ChannelType, GatewayCloseCode, HTTPCodes, InteractionType, @@ -11,8 +13,6 @@ TeamMembershipState, VoiceCloseCode, VoiceOpCode, - ApplicationCommandOptionType, - ChannelType ) from .loose import ( add_file, diff --git a/EpikCord/utils/enums.py b/EpikCord/utils/enums.py index 97aca92b..a3bad56f 100644 --- a/EpikCord/utils/enums.py +++ b/EpikCord/utils/enums.py @@ -407,9 +407,10 @@ class ApplicationCommandType(IntEnum): USER = 2 MESSAGE = 3 + class ApplicationCommandOptionType(IntEnum): - SUB_COMMAND = 1 - SUB_COMMAND_GROUP = 2 + SUB_COMMAND = 1 + SUB_COMMAND_GROUP = 2 STRING = 3 INTEGER = 4 BOOLEAN = 5 @@ -420,6 +421,7 @@ class ApplicationCommandOptionType(IntEnum): NUMBER = 10 ATTACHMENT = 11 + class ChannelType(IntEnum): GUILD_TEXT = 0 DM = 1 @@ -432,4 +434,4 @@ class ChannelType(IntEnum): PRIVATE_THREAD = 12 GUILD_STAGE_VOICE = 13 GUILD_DIRECTORY = 14 - GUILD_FORUM = 15 \ No newline at end of file + GUILD_FORUM = 15 From f2bd482a6fe8ff96a63a91108a7206a8af732922 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Wed, 22 Feb 2023 20:23:53 +0000 Subject: [PATCH 232/335] Create our own types --- EpikCord/client/commands.py | 108 +++++++++++++++------------------ EpikCord/interaction.py | 29 ++++++--- EpikCord/types/__init__.py | 33 ++++++++++ EpikCord/types/interactions.py | 91 +++++++++++++++++++++++++++ 4 files changed, 193 insertions(+), 68 deletions(-) create mode 100644 EpikCord/types/__init__.py create mode 100644 EpikCord/types/interactions.py diff --git a/EpikCord/client/commands.py b/EpikCord/client/commands.py index a7bd9e33..8ec40dcc 100644 --- a/EpikCord/client/commands.py +++ b/EpikCord/client/commands.py @@ -17,8 +17,8 @@ def __init__( name: str, callback: AsyncFunction, *, - guild_ids: List[int] = [], - name_localizations: List[Localization] = [], + guild_ids: Optional[List[int]] = None, + name_localizations: Optional[List[Localization]] = None, type: ApplicationCommandType = ApplicationCommandType.CHAT_INPUT, guild_only: Optional[bool] = None, default_member_permissions: Optional[Permissions] = None, @@ -52,10 +52,8 @@ def to_dict(self): payload[ "default_member_permissions" ] = self.default_member_permissions.value - if name_localizations := localization_list_to_dict( - self.name_localizations - ): - payload["name_localizations"] = name_localizations + if self.name_localizations: + payload["name_localizations"] = localization_list_to_dict(self.name_localizations) return payload @@ -69,8 +67,8 @@ def __init__( name: str, callback: AsyncFunction, *, - guild_ids: List[int] = [], - name_localizations: List[Localization] = [], + guild_ids: Optional[List[int]] = None, + name_localizations: Optional[List[Localization]] = None, guild_only: Optional[bool] = None, default_member_permissions: Optional[Permissions] = None, nsfw: bool = False, @@ -92,8 +90,8 @@ def __init__( name: str, callback: AsyncFunction, *, - guild_ids: List[int] = [], - name_localizations: List[Localization] = [], + guild_ids: Optional[List[int]] = None, + name_localizations: Optional[List[Localization]] = None, guild_only: Optional[bool] = None, default_member_permissions: Optional[Permissions] = None, nsfw: bool = False, @@ -110,7 +108,7 @@ def __init__( ) class ApplicationCommandOptionChoice: - def __init__(self, name: str, value: str, *, name_localizations: List[Localization] = []): + def __init__(self, name: str, value: str, *, name_localizations: Optional[List[Localization]] = None): self.name = name self.value = value self.name_localizations = name_localizations @@ -121,10 +119,8 @@ def to_dict(self): "value": self.value, } - if name_localizations := localization_list_to_dict( - self.name_localizations - ): - payload["name_localizations"] = name_localizations + if self.name_localizations: + payload["name_localizations"] = localization_list_to_dict(self.name_localizations) return payload @@ -135,8 +131,8 @@ def __init__( name: str, description: str, *, - name_localizations: List[Localization] = [], - description_localizations: List[Localization] = [], + name_localizations: Optional[List[Localization]] = None, + description_localizations: Optional[List[Localization]] = None, required: bool = False, type: ApplicationCommandOptionType, ): @@ -155,15 +151,11 @@ def to_dict(self): "type": self.type.value, } - if name_localizations := localization_list_to_dict( - self.name_localizations - ): - payload["name_localizations"] = name_localizations + if self.name_localizations: + payload["name_localizations"] = localization_list_to_dict(self.name_localizations) - if description_localizations := localization_list_to_dict( - self.description_localizations - ): - payload["description_localizations"] = description_localizations + if self.description_localizations: + payload["description_localizations"] = localization_list_to_dict(self.description_localizations) return payload @@ -173,11 +165,11 @@ def __init__( name: str, description: str, *, - name_localizations: List[Localization] = [], - description_localizations: List[Localization] = [], + name_localizations: Optional[List[Localization]] = None, + description_localizations: Optional[List[Localization]] = None, required: bool = False, type: ApplicationCommandOptionType, - choices: List[ApplicationCommandOptionChoice] = [], + choices: Optional[List[ApplicationCommandOptionChoice]] = None, autocomplete: bool = False ): super().__init__( @@ -205,11 +197,11 @@ def __init__( name: str, description: str, *, - name_localizations: List[Localization] = [], - description_localizations: List[Localization] = [], + name_localizations: Optional[List[Localization]] = None, + description_localizations: Optional[List[Localization]] = None, required: bool = False, type: ApplicationCommandOptionType, - choices: List[ApplicationCommandOptionChoice] = [], + choices: Optional[List[ApplicationCommandOptionChoice]] = None, autocomplete: bool = False, min_value: Optional[int] = None, max_value: Optional[int] = None, @@ -241,10 +233,10 @@ def __init__( name: str, description: str, *, - name_localizations: List[Localization] = [], - description_localizations: List[Localization] = [], + name_localizations: Optional[List[Localization]] = None, + description_localizations: Optional[List[Localization]] = None, required: bool = False, - choices: List[ApplicationCommandOptionChoice] = [], + choices: Optional[List[ApplicationCommandOptionChoice]] = None, autocomplete: bool = False, min_value: Optional[int] = None, max_value: Optional[int] = None, @@ -268,10 +260,10 @@ def __init__( name: str, description: str, *, - name_localizations: List[Localization] = [], - description_localizations: List[Localization] = [], + name_localizations: Optional[List[Localization]] = None, + description_localizations: Optional[List[Localization]] = None, required: bool = False, - choices: List[ApplicationCommandOptionChoice] = [], + choices: Optional[List[ApplicationCommandOptionChoice]] = None, autocomplete: bool = False, min_value: Optional[int] = None, max_value: Optional[int] = None, @@ -295,10 +287,10 @@ def __init__( name: str, description: str, *, - name_localizations: List[Localization] = [], - description_localizations: List[Localization] = [], + name_localizations: Optional[List[Localization]] = None, + description_localizations: Optional[List[Localization]] = None, required: bool = False, - choices: List[ApplicationCommandOptionChoice] = [], + choices: Optional[List[ApplicationCommandOptionChoice]] = None, autocomplete: bool = False, min_length: Optional[int] = None, max_length: Optional[int] = None, @@ -331,8 +323,8 @@ def __init__( name: str, description: str, *, - name_localizations: List[Localization] = [], - description_localizations: List[Localization] = [], + name_localizations: Optional[List[Localization]] = None, + description_localizations: Optional[List[Localization]] = None, required: bool = False, ): super().__init__( @@ -350,8 +342,8 @@ def __init__( name: str, description: str, *, - name_localizations: List[Localization] = [], - description_localizations: List[Localization] = [], + name_localizations: Optional[List[Localization]] = None, + description_localizations: Optional[List[Localization]] = None, required: bool = False, ): super().__init__( @@ -369,8 +361,8 @@ def __init__( name: str, description: str, *, - name_localizations: List[Localization] = [], - description_localizations: List[Localization] = [], + name_localizations: Optional[List[Localization]] = None, + description_localizations: Optional[List[Localization]] = None, required: bool = False, ): super().__init__( @@ -388,8 +380,8 @@ def __init__( name: str, description: str, *, - name_localizations: List[Localization] = [], - description_localizations: List[Localization] = [], + name_localizations: Optional[List[Localization]] = None, + description_localizations: Optional[List[Localization]] = None, required: bool = False, ): super().__init__( @@ -407,8 +399,8 @@ def __init__( name: str, description: str, *, - name_localizations: List[Localization] = [], - description_localizations: List[Localization] = [], + name_localizations: Optional[List[Localization]] = None, + description_localizations: Optional[List[Localization]] = None, required: bool = False, ): super().__init__( @@ -426,8 +418,8 @@ def __init__( name: str, description: str, *, - name_localizations: List[Localization] = [], - description_localizations: List[Localization] = [], + name_localizations: Optional[List[Localization]] = None, + description_localizations: Optional[List[Localization]] = None, required: bool = False, channel_types: Optional[List[ChannelType]] ): @@ -467,9 +459,9 @@ def __init__( description: str, callback: AsyncFunction, *, - guild_ids: List[int] = [], - name_localizations: List[Localization] = [], - description_localizations: List[Localization] = [], + guild_ids: Optional[List[int]] = None, + name_localizations: Optional[List[Localization]] = None, + description_localizations: Optional[List[Localization]] = None, type: ApplicationCommandType = ApplicationCommandType.CHAT_INPUT, guild_only: Optional[bool] = None, default_member_permissions: Optional[Permissions] = None, @@ -496,10 +488,8 @@ def to_dict(self): payload["description"] = self.description - if description_localizations := localization_list_to_dict( - self.description_localizations - ): - payload["description_localizations"] = description_localizations + if self.description_localizations: + payload["description_localizations"] = localization_list_to_dict(self.description_localizations) if self.options: payload["options"] = [option.to_dict() for option in self.options] # type: ignore # I still need to create the Option class diff --git a/EpikCord/interaction.py b/EpikCord/interaction.py index b7f6d6bd..25ec661c 100644 --- a/EpikCord/interaction.py +++ b/EpikCord/interaction.py @@ -1,8 +1,5 @@ +from typing import Union from discord_typings import ( - ApplicationCommandInteractionData, - InteractionData, - ChatInputCommandInteractionDataData, - ContextMenuInteractionDataData, ResolvedInteractionDataData, ) @@ -10,6 +7,14 @@ from .file import Attachment from .flags import Permissions from .guild import GuildMember, Role +from .types import ( + ChatInputInteractionData, + UserContextMenuInteractionData, + MessageContextMenuInteractionData, + InteractionData, + MessageContextMenuInteractionDataData, + UserContextMenuInteractionDataData +) from .user import User from .utils import ( ApplicationCommandType, @@ -66,6 +71,11 @@ def __init__(self, client: Client, data: ResolvedInteractionDataData): for k, v in data.get("attachments", {}).items() } +ApplicationCommandInteractionData = Union[ + ChatInputInteractionData, + UserContextMenuInteractionData, + MessageContextMenuInteractionData, +] class BaseApplicationCommandInteraction(BaseInteraction): def __init__(self, client: Client, data: ApplicationCommandInteractionData): @@ -81,12 +91,13 @@ def __init__(self, client: Client, data: ApplicationCommandInteractionData): ) -# class ChatInputCommandInteraction(): -# def __init__(self, client: Client, data: ApplicationCommandInteractionData): -# super().__init__(client, data) -# # OPTIONS +class ChatInputCommandInteraction(BaseApplicationCommandInteraction): + def __init__(self, client: Client, data: ChatInputInteractionData): + super().__init__(client, data) + # OPTIONS class BaseContextMenuInteraction(BaseApplicationCommandInteraction): - def __init__(self, client: Client, data): + def __init__(self, client: Client, data: Union[UserContextMenuInteractionData, MessageContextMenuInteractionData]): super().__init__(client, data) + self.data: Union[UserContextMenuInteractionDataData, MessageContextMenuInteractionDataData] self.target_id = int(self.data["target_id"]) \ No newline at end of file diff --git a/EpikCord/types/__init__.py b/EpikCord/types/__init__.py new file mode 100644 index 00000000..e1345636 --- /dev/null +++ b/EpikCord/types/__init__.py @@ -0,0 +1,33 @@ +from .interactions import ( + ChatInputInteractionDataData, + MessageContextMenuInteractionDataData, + UserContextMenuInteractionDataData, + ButtonInteractionDataData, + SelectMenuInteractionDataData, + ModalSubmitInteractionDataData, + ModalSubmitInteractionData, + SelectMenuInteractionData, + ButtonInteractionData, + UserContextMenuInteractionData, + MessageContextMenuInteractionData, + ChatInputInteractionData, + InteractionData, + InteractionDataData +) + +__all__ = ( + "ChatInputInteractionDataData", + "MessageContextMenuInteractionDataData", + "UserContextMenuInteractionDataData", + "ButtonInteractionDataData", + "SelectMenuInteractionDataData", + "ModalSubmitInteractionDataData", + "ModalSubmitInteractionData", + "SelectMenuInteractionData", + "ButtonInteractionData", + "UserContextMenuInteractionData", + "MessageContextMenuInteractionData", + "ChatInputInteractionData", + "InteractionDataData", + "InteractionData", +) \ No newline at end of file diff --git a/EpikCord/types/interactions.py b/EpikCord/types/interactions.py new file mode 100644 index 00000000..f085144d --- /dev/null +++ b/EpikCord/types/interactions.py @@ -0,0 +1,91 @@ +from typing import List, Literal, TypedDict, Union +from typing_extensions import NotRequired + +from discord_typings import ( + ActionRowData, + ApplicationCommandOptionInteractionData, + GuildMemberData, + MessageData, + ResolvedInteractionDataData, + UserData +) + +class BaseInteractionData(TypedDict): + id: str + application_id: str + type: Literal[1, 2, 3, 4, 5] + guild_id: NotRequired[str] + channel_id: NotRequired[str] + member: NotRequired[GuildMemberData] + user: NotRequired[UserData] + token: str + version: int + message: NotRequired[MessageData] + app_permissions: NotRequired[str] + locale: NotRequired[str] + guild_locale: NotRequired[str] + +class BaseApplicationCommandDataData(TypedDict): + id: str + name: str + resolved: NotRequired[ResolvedInteractionDataData] + guild_id: NotRequired[str] + + +class ChatInputInteractionDataData(BaseApplicationCommandDataData): + type: Literal[1] + options: List[ApplicationCommandOptionInteractionData] + +class BaseContextMenuInteractionDataData(BaseApplicationCommandDataData): + target_id: str + +class MessageContextMenuInteractionDataData(BaseContextMenuInteractionDataData): + type: Literal[2] + +class UserContextMenuInteractionDataData(BaseContextMenuInteractionDataData): + type: Literal[3] + +class ButtonInteractionDataData(TypedDict): + custom_id: str + component_type: Literal[2, 3, 5, 6, 7, 8] + +class SelectMenuInteractionDataData(ButtonInteractionDataData): + values: List[str] + +class ModalSubmitInteractionDataData(TypedDict): + custom_id: str + components: List[ActionRowData] + +class ChatInputInteractionData(BaseInteractionData): + data: ChatInputInteractionDataData + +class MessageContextMenuInteractionData(BaseInteractionData): + data: MessageContextMenuInteractionDataData + +class UserContextMenuInteractionData(BaseInteractionData): + data: UserContextMenuInteractionDataData + +class ButtonInteractionData(BaseInteractionData): + data: ButtonInteractionDataData + +class SelectMenuInteractionData(BaseInteractionData): + data: SelectMenuInteractionDataData + +class ModalSubmitInteractionData(BaseInteractionData): + data: ModalSubmitInteractionDataData + +InteractionDataData = Union[ + ButtonInteractionDataData, + SelectMenuInteractionDataData, + ChatInputInteractionDataData, + ModalSubmitInteractionDataData +] + +InteractionData = Union[ + ButtonInteractionData, + SelectMenuInteractionData, + ChatInputInteractionData, + ModalSubmitInteractionData, + MessageContextMenuInteractionData, + UserContextMenuInteractionData, +] \ No newline at end of file From 30c35a7d9e402b4977d1da8467a89cd35e2ceb6a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 22 Feb 2023 20:26:02 +0000 Subject: [PATCH 233/335] :art: Format code automatically --- EpikCord/client/commands.py | 30 +++++++++++++++++++++++------- EpikCord/interaction.py | 28 +++++++++++++++++++--------- EpikCord/types/__init__.py | 20 ++++++++++---------- EpikCord/types/interactions.py | 23 +++++++++++++++++++---- 4 files changed, 71 insertions(+), 30 deletions(-) diff --git a/EpikCord/client/commands.py b/EpikCord/client/commands.py index b8675854..0a805935 100644 --- a/EpikCord/client/commands.py +++ b/EpikCord/client/commands.py @@ -59,7 +59,9 @@ def to_dict(self): "default_member_permissions" ] = self.default_member_permissions.value if self.name_localizations: - payload["name_localizations"] = localization_list_to_dict(self.name_localizations) + payload["name_localizations"] = localization_list_to_dict( + self.name_localizations + ) return payload @@ -117,7 +119,13 @@ def __init__( class ApplicationCommandOptionChoice: - def __init__(self, name: str, value: str, *, name_localizations: Optional[List[Localization]] = None): + def __init__( + self, + name: str, + value: str, + *, + name_localizations: Optional[List[Localization]] = None, + ): self.name = name self.value = value self.name_localizations = name_localizations @@ -129,7 +137,9 @@ def to_dict(self): } if self.name_localizations: - payload["name_localizations"] = localization_list_to_dict(self.name_localizations) + payload["name_localizations"] = localization_list_to_dict( + self.name_localizations + ) return payload @@ -161,10 +171,14 @@ def to_dict(self): } if self.name_localizations: - payload["name_localizations"] = localization_list_to_dict(self.name_localizations) + payload["name_localizations"] = localization_list_to_dict( + self.name_localizations + ) if self.description_localizations: - payload["description_localizations"] = localization_list_to_dict(self.description_localizations) + payload["description_localizations"] = localization_list_to_dict( + self.description_localizations + ) return payload @@ -180,7 +194,7 @@ def __init__( required: bool = False, type: ApplicationCommandOptionType, choices: Optional[List[ApplicationCommandOptionChoice]] = None, - autocomplete: bool = False + autocomplete: bool = False, ): super().__init__( name, @@ -508,7 +522,9 @@ def to_dict(self): payload["description"] = self.description if self.description_localizations: - payload["description_localizations"] = localization_list_to_dict(self.description_localizations) + payload["description_localizations"] = localization_list_to_dict( + self.description_localizations + ) if self.options: payload["options"] = [option.to_dict() for option in self.options] # type: ignore # I still need to create the Option class diff --git a/EpikCord/interaction.py b/EpikCord/interaction.py index 3c1d56ad..28607dd4 100644 --- a/EpikCord/interaction.py +++ b/EpikCord/interaction.py @@ -1,7 +1,6 @@ from typing import Union -from discord_typings import ( - ResolvedInteractionDataData, -) + +from discord_typings import ResolvedInteractionDataData from .client import Client from .file import Attachment @@ -9,11 +8,11 @@ from .guild import GuildMember, Role from .types import ( ChatInputInteractionData, - UserContextMenuInteractionData, - MessageContextMenuInteractionData, InteractionData, + MessageContextMenuInteractionData, MessageContextMenuInteractionDataData, - UserContextMenuInteractionDataData + UserContextMenuInteractionData, + UserContextMenuInteractionDataData, ) from .user import User from .utils import ( @@ -71,11 +70,13 @@ def __init__(self, client: Client, data: ResolvedInteractionDataData): for k, v in data.get("attachments", {}).items() } + ApplicationCommandInteractionData = Union[ ChatInputInteractionData, UserContextMenuInteractionData, MessageContextMenuInteractionData, -] +] + class BaseApplicationCommandInteraction(BaseInteraction): def __init__(self, client: Client, data: ApplicationCommandInteractionData): @@ -98,7 +99,16 @@ def __init__(self, client: Client, data: ChatInputInteractionData): class BaseContextMenuInteraction(BaseApplicationCommandInteraction): - def __init__(self, client: Client, data: Union[UserContextMenuInteractionData, MessageContextMenuInteractionData]): + def __init__( + self, + client: Client, + data: Union[ + UserContextMenuInteractionData, MessageContextMenuInteractionData + ], + ): super().__init__(client, data) - self.data: Union[UserContextMenuInteractionDataData, MessageContextMenuInteractionDataData] + self.data: Union[ + UserContextMenuInteractionDataData, + MessageContextMenuInteractionDataData, + ] self.target_id = int(self.data["target_id"]) diff --git a/EpikCord/types/__init__.py b/EpikCord/types/__init__.py index e1345636..5e523c1e 100644 --- a/EpikCord/types/__init__.py +++ b/EpikCord/types/__init__.py @@ -1,18 +1,18 @@ from .interactions import ( + ButtonInteractionData, + ButtonInteractionDataData, + ChatInputInteractionData, ChatInputInteractionDataData, + InteractionData, + InteractionDataData, + MessageContextMenuInteractionData, MessageContextMenuInteractionDataData, - UserContextMenuInteractionDataData, - ButtonInteractionDataData, - SelectMenuInteractionDataData, - ModalSubmitInteractionDataData, ModalSubmitInteractionData, + ModalSubmitInteractionDataData, SelectMenuInteractionData, - ButtonInteractionData, + SelectMenuInteractionDataData, UserContextMenuInteractionData, - MessageContextMenuInteractionData, - ChatInputInteractionData, - InteractionData, - InteractionDataData + UserContextMenuInteractionDataData, ) __all__ = ( @@ -30,4 +30,4 @@ "ChatInputInteractionData", "InteractionDataData", "InteractionData", -) \ No newline at end of file +) diff --git a/EpikCord/types/interactions.py b/EpikCord/types/interactions.py index f085144d..512e7717 100644 --- a/EpikCord/types/interactions.py +++ b/EpikCord/types/interactions.py @@ -1,5 +1,4 @@ from typing import List, Literal, TypedDict, Union -from typing_extensions import NotRequired from discord_typings import ( ActionRowData, @@ -7,8 +6,10 @@ GuildMemberData, MessageData, ResolvedInteractionDataData, - UserData + UserData, ) +from typing_extensions import NotRequired + class BaseInteractionData(TypedDict): id: str @@ -25,6 +26,7 @@ class BaseInteractionData(TypedDict): locale: NotRequired[str] guild_locale: NotRequired[str] + class BaseApplicationCommandDataData(TypedDict): id: str name: str @@ -36,49 +38,62 @@ class ChatInputInteractionDataData(BaseApplicationCommandDataData): type: Literal[1] options: List[ApplicationCommandOptionInteractionData] + class BaseContextMenuInteractionDataData(BaseApplicationCommandDataData): target_id: str + class MessageContextMenuInteractionDataData(BaseContextMenuInteractionDataData): type: Literal[2] + class UserContextMenuInteractionDataData(BaseContextMenuInteractionDataData): type: Literal[3] + class ButtonInteractionDataData(TypedDict): custom_id: str component_type: Literal[2, 3, 5, 6, 7, 8] + class SelectMenuInteractionDataData(ButtonInteractionDataData): values: List[str] + class ModalSubmitInteractionDataData(TypedDict): custom_id: str components: List[ActionRowData] + class ChatInputInteractionData(BaseInteractionData): data: ChatInputInteractionDataData + class MessageContextMenuInteractionData(BaseInteractionData): data: MessageContextMenuInteractionDataData + class UserContextMenuInteractionData(BaseInteractionData): data: UserContextMenuInteractionDataData + class ButtonInteractionData(BaseInteractionData): data: ButtonInteractionDataData + class SelectMenuInteractionData(BaseInteractionData): data: SelectMenuInteractionDataData + class ModalSubmitInteractionData(BaseInteractionData): data: ModalSubmitInteractionDataData + InteractionDataData = Union[ ButtonInteractionDataData, SelectMenuInteractionDataData, ChatInputInteractionDataData, - ModalSubmitInteractionDataData + ModalSubmitInteractionDataData, ] InteractionData = Union[ @@ -88,4 +103,4 @@ class ModalSubmitInteractionData(BaseInteractionData): ModalSubmitInteractionData, MessageContextMenuInteractionData, UserContextMenuInteractionData, -] \ No newline at end of file +] From 7b9fb7351ab7bf73279799cbe120ff5d77a24171 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Wed, 22 Feb 2023 20:32:25 +0000 Subject: [PATCH 234/335] Fix import --- EpikCord/client/client.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index 9eee4c59..f2720947 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -1,11 +1,9 @@ import asyncio from typing import Optional -from EpikCord.utils.types import AsyncFunction - from ..flags import Intents from ..presence import Presence -from ..utils import OpCode, cleanup_loop, singleton +from ..utils import OpCode, cleanup_loop, singleton, AsyncFunction from .http import APIVersion, HTTPClient from .websocket import WebSocketClient From dfa9d8acaea020b953fe75a2d2109b336f406260 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 22 Feb 2023 20:33:23 +0000 Subject: [PATCH 235/335] :art: Format code automatically --- EpikCord/client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index f2720947..2132d979 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -3,7 +3,7 @@ from ..flags import Intents from ..presence import Presence -from ..utils import OpCode, cleanup_loop, singleton, AsyncFunction +from ..utils import AsyncFunction, OpCode, cleanup_loop, singleton from .http import APIVersion, HTTPClient from .websocket import WebSocketClient From b3ab33265287b3ff4f667d2c24400ba4c6efaf74 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Wed, 22 Feb 2023 20:33:34 +0000 Subject: [PATCH 236/335] Fix check --- EpikCord/client/commands.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/EpikCord/client/commands.py b/EpikCord/client/commands.py index 0a805935..c023a5d6 100644 --- a/EpikCord/client/commands.py +++ b/EpikCord/client/commands.py @@ -29,21 +29,15 @@ def __init__( self.callback = callback self.name_localizations = name_localizations self.type = type - self.guild_only = ( - guild_only - if guild_only is not None - else False - if not self.guild_ids - else True - ) + self.guild_only = guild_only or (guild_ids) self.default_member_permissions = default_member_permissions self.nsfw = nsfw - if self.guild_ids and not self.guild_only: raise ValueError( "Guild IDs cannot be set if the command is not set to guild only." ) + def to_dict(self): payload = { "name": self.name, From 3627036b104ea302776c90e026ea408a163a32e2 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Wed, 22 Feb 2023 20:33:52 +0000 Subject: [PATCH 237/335] Move check up --- EpikCord/client/commands.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/EpikCord/client/commands.py b/EpikCord/client/commands.py index c023a5d6..9ce302d8 100644 --- a/EpikCord/client/commands.py +++ b/EpikCord/client/commands.py @@ -24,6 +24,10 @@ def __init__( default_member_permissions: Optional[Permissions] = None, nsfw: bool = False, ): + if guild_ids and not guild_only: + raise ValueError( + "Guild IDs cannot be set if the command is not set to guild only." + ) self.name = name self.guild_ids = guild_ids self.callback = callback @@ -32,10 +36,6 @@ def __init__( self.guild_only = guild_only or (guild_ids) self.default_member_permissions = default_member_permissions self.nsfw = nsfw - if self.guild_ids and not self.guild_only: - raise ValueError( - "Guild IDs cannot be set if the command is not set to guild only." - ) def to_dict(self): From 71bd4060f9da61b4ac71e945613c6f2ba77fb854 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Wed, 22 Feb 2023 20:41:19 +0000 Subject: [PATCH 238/335] Use ApplicationCommandChoiceData instead of Dict typehint --- EpikCord/client/commands.py | 5 +++-- EpikCord/types/__init__.py | 2 ++ EpikCord/types/interactions.py | 7 ++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/EpikCord/client/commands.py b/EpikCord/client/commands.py index 9ce302d8..253d73df 100644 --- a/EpikCord/client/commands.py +++ b/EpikCord/client/commands.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Optional, Union +from typing import List, Optional, Union from ..flags import Permissions from ..locales import Localization @@ -9,6 +9,7 @@ ChannelType, localization_list_to_dict, ) +from ..types import ApplicationCommandChoiceData class BaseClientCommand: @@ -125,7 +126,7 @@ def __init__( self.name_localizations = name_localizations def to_dict(self): - payload: Dict[str, Union[str, Dict[str, str]]] = { + payload: ApplicationCommandChoiceData = { "name": self.name, "value": self.value, } diff --git a/EpikCord/types/__init__.py b/EpikCord/types/__init__.py index 5e523c1e..3a30b2f4 100644 --- a/EpikCord/types/__init__.py +++ b/EpikCord/types/__init__.py @@ -13,6 +13,7 @@ SelectMenuInteractionDataData, UserContextMenuInteractionData, UserContextMenuInteractionDataData, + ApplicationCommandChoiceData ) __all__ = ( @@ -30,4 +31,5 @@ "ChatInputInteractionData", "InteractionDataData", "InteractionData", + "ApplicationCommandChoiceData" ) diff --git a/EpikCord/types/interactions.py b/EpikCord/types/interactions.py index 512e7717..97cf6dff 100644 --- a/EpikCord/types/interactions.py +++ b/EpikCord/types/interactions.py @@ -1,4 +1,4 @@ -from typing import List, Literal, TypedDict, Union +from typing import Dict, List, Literal, TypedDict, Union from discord_typings import ( ActionRowData, @@ -104,3 +104,8 @@ class ModalSubmitInteractionData(BaseInteractionData): MessageContextMenuInteractionData, UserContextMenuInteractionData, ] + +class ApplicationCommandChoiceData(TypedDict): + name: str + value: Union[str, int, float] + name_localizations: NotRequired[Dict[str, str]] \ No newline at end of file From 953451882e3056320ea66b2fe6b9aa90f94f16ac Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 22 Feb 2023 20:42:10 +0000 Subject: [PATCH 239/335] :art: Format code automatically --- EpikCord/client/commands.py | 3 +-- EpikCord/types/__init__.py | 4 ++-- EpikCord/types/interactions.py | 3 ++- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/EpikCord/client/commands.py b/EpikCord/client/commands.py index 253d73df..a59c8f3f 100644 --- a/EpikCord/client/commands.py +++ b/EpikCord/client/commands.py @@ -2,6 +2,7 @@ from ..flags import Permissions from ..locales import Localization +from ..types import ApplicationCommandChoiceData from ..utils import ( ApplicationCommandOptionType, ApplicationCommandType, @@ -9,7 +10,6 @@ ChannelType, localization_list_to_dict, ) -from ..types import ApplicationCommandChoiceData class BaseClientCommand: @@ -38,7 +38,6 @@ def __init__( self.default_member_permissions = default_member_permissions self.nsfw = nsfw - def to_dict(self): payload = { "name": self.name, diff --git a/EpikCord/types/__init__.py b/EpikCord/types/__init__.py index 3a30b2f4..b0b879be 100644 --- a/EpikCord/types/__init__.py +++ b/EpikCord/types/__init__.py @@ -1,4 +1,5 @@ from .interactions import ( + ApplicationCommandChoiceData, ButtonInteractionData, ButtonInteractionDataData, ChatInputInteractionData, @@ -13,7 +14,6 @@ SelectMenuInteractionDataData, UserContextMenuInteractionData, UserContextMenuInteractionDataData, - ApplicationCommandChoiceData ) __all__ = ( @@ -31,5 +31,5 @@ "ChatInputInteractionData", "InteractionDataData", "InteractionData", - "ApplicationCommandChoiceData" + "ApplicationCommandChoiceData", ) diff --git a/EpikCord/types/interactions.py b/EpikCord/types/interactions.py index 97cf6dff..a0d7ec93 100644 --- a/EpikCord/types/interactions.py +++ b/EpikCord/types/interactions.py @@ -105,7 +105,8 @@ class ModalSubmitInteractionData(BaseInteractionData): UserContextMenuInteractionData, ] + class ApplicationCommandChoiceData(TypedDict): name: str value: Union[str, int, float] - name_localizations: NotRequired[Dict[str, str]] \ No newline at end of file + name_localizations: NotRequired[Dict[str, str]] From 7d669bdd1b223a5f10be0052b9319ba3b484f1e7 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Wed, 22 Feb 2023 20:42:12 +0000 Subject: [PATCH 240/335] Add return type hint --- EpikCord/client/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EpikCord/client/commands.py b/EpikCord/client/commands.py index 253d73df..baf100a1 100644 --- a/EpikCord/client/commands.py +++ b/EpikCord/client/commands.py @@ -125,7 +125,7 @@ def __init__( self.value = value self.name_localizations = name_localizations - def to_dict(self): + def to_dict(self) -> ApplicationCommandChoiceData: payload: ApplicationCommandChoiceData = { "name": self.name, "value": self.value, From 3a6e22d8d1e3f8a359fd733854df7d7ddb305b0f Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Wed, 22 Feb 2023 20:44:11 +0000 Subject: [PATCH 241/335] Add check for min_length/value > max_length/value --- EpikCord/client/commands.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/EpikCord/client/commands.py b/EpikCord/client/commands.py index e48b9a31..4db90a88 100644 --- a/EpikCord/client/commands.py +++ b/EpikCord/client/commands.py @@ -238,6 +238,10 @@ def __init__( self.min_value = min_value self.max_value = max_value + if min_value and max_value and min_value > max_value: + raise ValueError("min_value must be less than max_value") + + def to_dict(self): payload = super().to_dict() if self.min_value: @@ -330,6 +334,9 @@ def __init__( self.min_length = min_length self.max_length = max_length + if min_length and max_length and min_length > max_length: + raise ValueError("min_length must be less than max_length") + def to_dict(self): payload = super().to_dict() if self.min_length: From d57dd33e61e43df90c1e112726e2dd2ea86fa04c Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Wed, 22 Feb 2023 20:45:12 +0000 Subject: [PATCH 242/335] Make channel_types Optional Co-authored-by: Sigmanificient --- EpikCord/client/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EpikCord/client/commands.py b/EpikCord/client/commands.py index 4db90a88..9d757686 100644 --- a/EpikCord/client/commands.py +++ b/EpikCord/client/commands.py @@ -455,7 +455,7 @@ def __init__( name_localizations: Optional[List[Localization]] = None, description_localizations: Optional[List[Localization]] = None, required: bool = False, - channel_types: Optional[List[ChannelType]], + channel_types: Optional[List[ChannelType]] = None, ): super().__init__( name, From 9100b4dedd44f2b49271304874d42858d9ec75e0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 22 Feb 2023 20:46:23 +0000 Subject: [PATCH 243/335] :art: Format code automatically --- EpikCord/client/commands.py | 1 - 1 file changed, 1 deletion(-) diff --git a/EpikCord/client/commands.py b/EpikCord/client/commands.py index 9d757686..3d208668 100644 --- a/EpikCord/client/commands.py +++ b/EpikCord/client/commands.py @@ -241,7 +241,6 @@ def __init__( if min_value and max_value and min_value > max_value: raise ValueError("min_value must be less than max_value") - def to_dict(self): payload = super().to_dict() if self.min_value: From 7f829b9f9a9666011e425bc464f7887ac4aac3cb Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Thu, 23 Feb 2023 21:32:33 +0000 Subject: [PATCH 244/335] Add more types --- EpikCord/client/commands.py | 12 ++++-- EpikCord/types/__init__.py | 26 +++++++++++ EpikCord/types/interactions.py | 79 +++++++++++++++++++++++++++++++++- 3 files changed, 112 insertions(+), 5 deletions(-) diff --git a/EpikCord/client/commands.py b/EpikCord/client/commands.py index 3d208668..d0c2fc6f 100644 --- a/EpikCord/client/commands.py +++ b/EpikCord/client/commands.py @@ -1,8 +1,14 @@ from typing import List, Optional, Union +from typing_extensions import Literal from ..flags import Permissions from ..locales import Localization -from ..types import ApplicationCommandChoiceData +from ..types import ( + ApplicationCommandChoiceData, + ChatInputCommandPayload, + ApplicationCommandOptionData + +) from ..utils import ( ApplicationCommandOptionType, ApplicationCommandType, @@ -500,7 +506,7 @@ def __init__( guild_only: Optional[bool] = None, default_member_permissions: Optional[Permissions] = None, nsfw: bool = False, - options: Optional[List[str]] = None, + options: Optional[List[ApplicationCommandOption]] = None, ): super().__init__( name, @@ -526,6 +532,6 @@ def to_dict(self): self.description_localizations ) if self.options: - payload["options"] = [option.to_dict() for option in self.options] # type: ignore # I still need to create the Option class + payload["options"] = [option.to_dict() for option in self.options] return payload diff --git a/EpikCord/types/__init__.py b/EpikCord/types/__init__.py index b0b879be..773a2754 100644 --- a/EpikCord/types/__init__.py +++ b/EpikCord/types/__init__.py @@ -14,6 +14,19 @@ SelectMenuInteractionDataData, UserContextMenuInteractionData, UserContextMenuInteractionDataData, + ChatInputCommandPayload, + MessageContextMenuCommandPayload, + UserContextMenuCommandPayload, + StringOptionData, + IntegerOptionData, + BooleanOptionData, + UserOptionData, + ChannelOptionData, + RoleOptionData, + MentionableOptionData, + NumberOptionData, + AttachmentOptionData, + ApplicationCommandOptionData ) __all__ = ( @@ -32,4 +45,17 @@ "InteractionDataData", "InteractionData", "ApplicationCommandChoiceData", + "ChatInputCommandPayload", + "MessageContextMenuCommandPayload", + "UserContextMenuCommandPayload", + "StringOptionData", + "IntegerOptionData", + "BooleanOptionData", + "UserOptionData", + "ChannelOptionData", + "RoleOptionData", + "MentionableOptionData", + "NumberOptionData", + "AttachmentOptionData", + "ApplicationCommandOptionData" ) diff --git a/EpikCord/types/interactions.py b/EpikCord/types/interactions.py index a0d7ec93..2930af20 100644 --- a/EpikCord/types/interactions.py +++ b/EpikCord/types/interactions.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Literal, TypedDict, Union +from typing import Dict, List, Literal, Optional, TypedDict, Union from discord_typings import ( ActionRowData, @@ -10,7 +10,6 @@ ) from typing_extensions import NotRequired - class BaseInteractionData(TypedDict): id: str application_id: str @@ -105,8 +104,84 @@ class ModalSubmitInteractionData(BaseInteractionData): UserContextMenuInteractionData, ] +class BaseApplicationCommandOptionData(TypedDict): + type: Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] + name: str + name_localizations: NotRequired[Dict[str, str]] + description: str + description_localizations: NotRequired[Dict[str, str]] + +class RequiredOptionData(BaseApplicationCommandOptionData): + required: NotRequired[bool] class ApplicationCommandChoiceData(TypedDict): name: str value: Union[str, int, float] name_localizations: NotRequired[Dict[str, str]] + +class BaseStringIntegerNumberOptionData(RequiredOptionData): + choices: NotRequired[ApplicationCommandChoiceData] + autocomplete: bool + +class StringOptionData(BaseStringIntegerNumberOptionData): + min_length: NotRequired[int] + max_length: NotRequired[int] + +class BaseNumberOptionData(BaseStringIntegerNumberOptionData): + min_value: NotRequired[int] + max_value: NotRequired[int] + +class IntegerOptionData(BaseNumberOptionData): + ... + +class NumberOptionData(BaseNumberOptionData): + ... + +class BooleanOptionData(RequiredOptionData): + ... + +class UserOptionData(RequiredOptionData): + ... + +class ChannelOptionData(RequiredOptionData): + channel_types: NotRequired[List[Literal[0, 1, 2, 3, 4, 5, 10, 11, 12, 13, 14, 15]]] + +class RoleOptionData(RequiredOptionData): + ... + +class AttachmentOptionData(RequiredOptionData): + ... + +class MentionableOptionData(RequiredOptionData): + ... + +ApplicationCommandOptionData = Union[ + StringOptionData, + IntegerOptionData, + BooleanOptionData, + UserOptionData, + ChannelOptionData, + RoleOptionData, + MentionableOptionData, + NumberOptionData, + AttachmentOptionData, +] + +class BaseApplicationCommandPayload(TypedDict): + name: str + name_localizations: NotRequired[Dict[str, str]] + type: Literal[1, 2, 3] + dm_permission: NotRequired[bool] + default_member_permissions: NotRequired[Optional[str]] + nsfw: NotRequired[bool] + +class ChatInputCommandPayload(BaseApplicationCommandPayload): + description: str + description_localizations: NotRequired[Dict[str, str]] + options: NotRequired[List[ApplicationCommandOptionData]] + +class MessageContextMenuCommandPayload(BaseApplicationCommandPayload): + ... + +class UserContextMenuCommandPayload(BaseApplicationCommandPayload): + ... \ No newline at end of file From eebd2ab981f240125b92b86b9f20abfa86496e0d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 23 Feb 2023 21:33:29 +0000 Subject: [PATCH 245/335] :art: Format code automatically --- EpikCord/client/commands.py | 4 ++-- EpikCord/types/__init__.py | 26 +++++++++++++------------- EpikCord/types/interactions.py | 26 ++++++++++++++++++++++++-- 3 files changed, 39 insertions(+), 17 deletions(-) diff --git a/EpikCord/client/commands.py b/EpikCord/client/commands.py index d0c2fc6f..2be95f80 100644 --- a/EpikCord/client/commands.py +++ b/EpikCord/client/commands.py @@ -1,13 +1,13 @@ from typing import List, Optional, Union + from typing_extensions import Literal from ..flags import Permissions from ..locales import Localization from ..types import ( ApplicationCommandChoiceData, + ApplicationCommandOptionData, ChatInputCommandPayload, - ApplicationCommandOptionData - ) from ..utils import ( ApplicationCommandOptionType, diff --git a/EpikCord/types/__init__.py b/EpikCord/types/__init__.py index 773a2754..aa25269b 100644 --- a/EpikCord/types/__init__.py +++ b/EpikCord/types/__init__.py @@ -1,32 +1,32 @@ from .interactions import ( ApplicationCommandChoiceData, + ApplicationCommandOptionData, + AttachmentOptionData, + BooleanOptionData, ButtonInteractionData, ButtonInteractionDataData, + ChannelOptionData, + ChatInputCommandPayload, ChatInputInteractionData, ChatInputInteractionDataData, + IntegerOptionData, InteractionData, InteractionDataData, + MentionableOptionData, + MessageContextMenuCommandPayload, MessageContextMenuInteractionData, MessageContextMenuInteractionDataData, ModalSubmitInteractionData, ModalSubmitInteractionDataData, + NumberOptionData, + RoleOptionData, SelectMenuInteractionData, SelectMenuInteractionDataData, + StringOptionData, + UserContextMenuCommandPayload, UserContextMenuInteractionData, UserContextMenuInteractionDataData, - ChatInputCommandPayload, - MessageContextMenuCommandPayload, - UserContextMenuCommandPayload, - StringOptionData, - IntegerOptionData, - BooleanOptionData, UserOptionData, - ChannelOptionData, - RoleOptionData, - MentionableOptionData, - NumberOptionData, - AttachmentOptionData, - ApplicationCommandOptionData ) __all__ = ( @@ -57,5 +57,5 @@ "MentionableOptionData", "NumberOptionData", "AttachmentOptionData", - "ApplicationCommandOptionData" + "ApplicationCommandOptionData", ) diff --git a/EpikCord/types/interactions.py b/EpikCord/types/interactions.py index 2930af20..61952263 100644 --- a/EpikCord/types/interactions.py +++ b/EpikCord/types/interactions.py @@ -10,6 +10,7 @@ ) from typing_extensions import NotRequired + class BaseInteractionData(TypedDict): id: str application_id: str @@ -104,6 +105,7 @@ class ModalSubmitInteractionData(BaseInteractionData): UserContextMenuInteractionData, ] + class BaseApplicationCommandOptionData(TypedDict): type: Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] name: str @@ -111,50 +113,66 @@ class BaseApplicationCommandOptionData(TypedDict): description: str description_localizations: NotRequired[Dict[str, str]] + class RequiredOptionData(BaseApplicationCommandOptionData): required: NotRequired[bool] + class ApplicationCommandChoiceData(TypedDict): name: str value: Union[str, int, float] name_localizations: NotRequired[Dict[str, str]] + class BaseStringIntegerNumberOptionData(RequiredOptionData): choices: NotRequired[ApplicationCommandChoiceData] autocomplete: bool + class StringOptionData(BaseStringIntegerNumberOptionData): min_length: NotRequired[int] max_length: NotRequired[int] + class BaseNumberOptionData(BaseStringIntegerNumberOptionData): min_value: NotRequired[int] max_value: NotRequired[int] + class IntegerOptionData(BaseNumberOptionData): ... + class NumberOptionData(BaseNumberOptionData): ... + class BooleanOptionData(RequiredOptionData): ... + class UserOptionData(RequiredOptionData): ... + class ChannelOptionData(RequiredOptionData): - channel_types: NotRequired[List[Literal[0, 1, 2, 3, 4, 5, 10, 11, 12, 13, 14, 15]]] + channel_types: NotRequired[ + List[Literal[0, 1, 2, 3, 4, 5, 10, 11, 12, 13, 14, 15]] + ] + class RoleOptionData(RequiredOptionData): ... + class AttachmentOptionData(RequiredOptionData): ... + class MentionableOptionData(RequiredOptionData): ... + ApplicationCommandOptionData = Union[ StringOptionData, IntegerOptionData, @@ -167,6 +185,7 @@ class MentionableOptionData(RequiredOptionData): AttachmentOptionData, ] + class BaseApplicationCommandPayload(TypedDict): name: str name_localizations: NotRequired[Dict[str, str]] @@ -175,13 +194,16 @@ class BaseApplicationCommandPayload(TypedDict): default_member_permissions: NotRequired[Optional[str]] nsfw: NotRequired[bool] + class ChatInputCommandPayload(BaseApplicationCommandPayload): description: str description_localizations: NotRequired[Dict[str, str]] options: NotRequired[List[ApplicationCommandOptionData]] + class MessageContextMenuCommandPayload(BaseApplicationCommandPayload): ... + class UserContextMenuCommandPayload(BaseApplicationCommandPayload): - ... \ No newline at end of file + ... From 7d636cc569ad7e9b3c0de4ec6cdd5018307ed436 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Fri, 24 Feb 2023 14:59:01 +0100 Subject: [PATCH 246/335] :green_heart: Ignore .git folder within codespell --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dacf66a1..4e3247e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,7 +61,7 @@ jobs: python-version: 3.8 cache: 'pip' - run: pip install codespell - - run: codespell --skip="./docs/* ./git/*" + - run: codespell --skip="./docs/* ./.git/*" lint: if: ${{ always() }} From bc5d35abcf342f3ef75ae1f894264d85c5e051b5 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Fri, 24 Feb 2023 15:08:27 +0100 Subject: [PATCH 247/335] :recycle: Passing lint --- EpikCord/client/commands.py | 6 +----- EpikCord/guild.py | 4 +++- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/EpikCord/client/commands.py b/EpikCord/client/commands.py index 2be95f80..908992ef 100644 --- a/EpikCord/client/commands.py +++ b/EpikCord/client/commands.py @@ -1,13 +1,9 @@ from typing import List, Optional, Union -from typing_extensions import Literal - from ..flags import Permissions from ..locales import Localization from ..types import ( ApplicationCommandChoiceData, - ApplicationCommandOptionData, - ChatInputCommandPayload, ) from ..utils import ( ApplicationCommandOptionType, @@ -33,7 +29,7 @@ def __init__( ): if guild_ids and not guild_only: raise ValueError( - "Guild IDs cannot be set if the command is not set to guild only." + "Guild IDs cannot be set if a command is not set to guild only." ) self.name = name self.guild_ids = guild_ids diff --git a/EpikCord/guild.py b/EpikCord/guild.py index 0fcf4624..49090497 100644 --- a/EpikCord/guild.py +++ b/EpikCord/guild.py @@ -28,7 +28,9 @@ def __init__(self, client: Client, data: GuildMemberData): ) self.deaf = data["deaf"] self.mute = data["mute"] - self.flags = GuildMemberFlags(data["flags"]) # type: ignore # DiscordTyping/32 + + raw_flags = data["flags"] # type: ignore # DiscordTyping/32 + self.flags = GuildMemberFlags(raw_flags) self.pending = data.get("pending") self.permissions = instance_or_none( Permissions, data.get("permissions") From 573302af5c045bf0bee8819c49d67f6b65b744d6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 24 Feb 2023 14:09:13 +0000 Subject: [PATCH 248/335] :art: Format code automatically --- EpikCord/client/commands.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/EpikCord/client/commands.py b/EpikCord/client/commands.py index 908992ef..b806b67b 100644 --- a/EpikCord/client/commands.py +++ b/EpikCord/client/commands.py @@ -2,9 +2,7 @@ from ..flags import Permissions from ..locales import Localization -from ..types import ( - ApplicationCommandChoiceData, -) +from ..types import ApplicationCommandChoiceData from ..utils import ( ApplicationCommandOptionType, ApplicationCommandType, From a5ae2c67db6148c2c76c77dc940c721146e94ea9 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Fri, 24 Feb 2023 15:13:32 +0100 Subject: [PATCH 249/335] :green_heart: Fix codespell argument passing (WHY codespell) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e3247e8..0ea3ba1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,7 +61,7 @@ jobs: python-version: 3.8 cache: 'pip' - run: pip install codespell - - run: codespell --skip="./docs/* ./.git/*" + - run: codespell --skip="./docs/*" --skip="./.git/*" lint: if: ${{ always() }} From 2c03b68992a8f00a3732206593316b071aa35cf1 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Tue, 28 Feb 2023 21:11:07 +0000 Subject: [PATCH 250/335] url -> URL --- EpikCord/client/websocket.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 1eec5008..004996f7 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -216,6 +216,7 @@ async def reconnect(self, _): async def invalid_session(self, resumable: bool): if self.client.ws: await self.client.ws.close() + await self.client.connect(resume=resumable) @@ -351,9 +352,9 @@ async def connect(self, *, resume: bool = False): ) if not self.resume_url and resume: - raise ValueError("Cannot resume without a resume url") + raise ValueError("Cannot resume without a resume URL") elif not url: - raise ValueError(f"Cannot connect to url {url} (resume: {resume})") + raise ValueError(f"Cannot connect to URL {url} (resume: {resume})") self.ws = await self.http.ws_connect(url) asyncio.create_task(self.rate_limiter.reset.start()) From b47a491b7a9f7dc943e631715bee23315a1f479d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Feb 2023 21:12:03 +0000 Subject: [PATCH 251/335] :art: Format code automatically --- EpikCord/client/websocket.py | 1 - 1 file changed, 1 deletion(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 004996f7..4741a6ce 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -216,7 +216,6 @@ async def reconnect(self, _): async def invalid_session(self, resumable: bool): if self.client.ws: await self.client.ws.close() - await self.client.connect(resume=resumable) From 238096418e0748012efbff25e539bc9598337fd4 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Tue, 14 Mar 2023 23:11:08 +0000 Subject: [PATCH 252/335] Test webhook --- EpikCord/application.py | 1 + 1 file changed, 1 insertion(+) diff --git a/EpikCord/application.py b/EpikCord/application.py index d6fea815..27b57602 100644 --- a/EpikCord/application.py +++ b/EpikCord/application.py @@ -12,6 +12,7 @@ def __init__(self, data: InstallParamsData): self.permissions = Permissions(int(data["permissions"])) + class TeamMember: def __init__(self, data: TeamMemberData): self.membership_state = TeamMembershipState(data["membership_state"]) From 6a6dfb15920304d94953354b340a042bb354d723 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 14 Mar 2023 23:12:07 +0000 Subject: [PATCH 253/335] :art: Format code automatically --- EpikCord/application.py | 1 - 1 file changed, 1 deletion(-) diff --git a/EpikCord/application.py b/EpikCord/application.py index 27b57602..d6fea815 100644 --- a/EpikCord/application.py +++ b/EpikCord/application.py @@ -12,7 +12,6 @@ def __init__(self, data: InstallParamsData): self.permissions = Permissions(int(data["permissions"])) - class TeamMember: def __init__(self, data: TeamMemberData): self.membership_state = TeamMembershipState(data["membership_state"]) From c60f25d977d99dc98ff4702ff57977e7aa697f83 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Tue, 14 Mar 2023 23:14:08 +0000 Subject: [PATCH 254/335] Try webhook again --- EpikCord/application.py | 1 + 1 file changed, 1 insertion(+) diff --git a/EpikCord/application.py b/EpikCord/application.py index d6fea815..27b57602 100644 --- a/EpikCord/application.py +++ b/EpikCord/application.py @@ -12,6 +12,7 @@ def __init__(self, data: InstallParamsData): self.permissions = Permissions(int(data["permissions"])) + class TeamMember: def __init__(self, data: TeamMemberData): self.membership_state = TeamMembershipState(data["membership_state"]) From 07979544833798214099fd6d0f5a44d32f6bdadd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 14 Mar 2023 23:14:56 +0000 Subject: [PATCH 255/335] :art: Format code automatically --- EpikCord/application.py | 1 - 1 file changed, 1 deletion(-) diff --git a/EpikCord/application.py b/EpikCord/application.py index 27b57602..d6fea815 100644 --- a/EpikCord/application.py +++ b/EpikCord/application.py @@ -12,7 +12,6 @@ def __init__(self, data: InstallParamsData): self.permissions = Permissions(int(data["permissions"])) - class TeamMember: def __init__(self, data: TeamMemberData): self.membership_state = TeamMembershipState(data["membership_state"]) From 64fad43f3d2e5e37d02eafb9b517d228145f73bc Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Thu, 16 Mar 2023 21:47:53 +0000 Subject: [PATCH 256/335] WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW --- EpikCord/exceptions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/EpikCord/exceptions.py b/EpikCord/exceptions.py index 3d7642b7..6d8697ca 100644 --- a/EpikCord/exceptions.py +++ b/EpikCord/exceptions.py @@ -13,6 +13,7 @@ class LocatedError: path: str + class HTTPException(EpikCordException): def __init__(self, data: Dict): self.body = data From d41410de189e0245bae085c795039a772cc23532 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 16 Mar 2023 21:49:00 +0000 Subject: [PATCH 257/335] :art: Format code automatically --- EpikCord/exceptions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/EpikCord/exceptions.py b/EpikCord/exceptions.py index 6d8697ca..3d7642b7 100644 --- a/EpikCord/exceptions.py +++ b/EpikCord/exceptions.py @@ -13,7 +13,6 @@ class LocatedError: path: str - class HTTPException(EpikCordException): def __init__(self, data: Dict): self.body = data From eeb5c38ebaa7a8f6beaeb74e510a96bb9dab179b Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Thu, 16 Mar 2023 23:38:07 +0100 Subject: [PATCH 258/335] :wrench: Add flake configuration --- .editorconfig | 4 ++++ flake.lock | 43 +++++++++++++++++++++++++++++++++++++++++++ flake.nix | 21 +++++++++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/.editorconfig b/.editorconfig index 59da8a53..21fcf838 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,6 +9,10 @@ insert_final_newline = false max_line_length = 80 tab_width = 4 +[*.nix] +indent_size = 2 +tab_width = 2 + [*.yml] tab_width = 2 indent_size = 2 diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..9772e4a1 --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1678872516, + "narHash": "sha256-/E1YwtMtFAu2KUQKV/1+KFuReYPANM2Rzehk84VxVoc=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "9b8e5abb18324c7fe9f07cb100c3cd4a29cda8b8", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-22.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "utils": "utils" + } + }, + "utils": { + "locked": { + "lastModified": 1678901627, + "narHash": "sha256-U02riOqrKKzwjsxc/400XnElV+UtPUQWpANPlyazjH0=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "93a2b84fc4b70d9e089d029deacc3583435c2ed6", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..5ba9865b --- /dev/null +++ b/flake.nix @@ -0,0 +1,21 @@ +{ + description = "EpikCord Python Library"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-22.11"; + utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, utils } @inputs: + utils.lib.eachDefaultSystem (system: + with import nixpkgs { inherit system; }; { + devShells.default = mkShell { + venvDir = "venv"; + buildInputs = [ + pkgs.gnumake + pkgs.python310Full + pkgs.python310Packages.venvShellHook + ]; + }; + }); +} From b99f18443ca8f0658f4ea1a0cdd36b3bf9b6d2e0 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Thu, 16 Mar 2023 23:40:23 +0100 Subject: [PATCH 259/335] :wrench: Add git attributes --- .gitattributes | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..1457a771 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=lf +* eol=lf From a424c528a0d97cd01537623449f1dd86ec519814 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 16 Mar 2023 22:41:43 +0000 Subject: [PATCH 260/335] :art: Format code automatically --- docs/EpikCord_Logo.png | Bin 1334607 -> 1334587 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/EpikCord_Logo.png b/docs/EpikCord_Logo.png index c9624c658b78cd945df3df2d0eb4a896c0ba01be..b6b4fed285b2d54502c3a7c23c0078a80d95e351 100644 GIT binary patch delta 176 zcmV;h08jtVx>38jQ3Q!lPDil?e8sbZ#nga@S4IJcS4IMdS4IQ3S4ITcoVPLK1e%eD z%yb2Z%yb5a%yb90%ybB~Vuym)2)BaQ2^N5dnO6$8nO6(8D7R363kF2DSHug3?zi5u z3@IA72+a(nAcw8(47aWA4aiToc0CS=K8OE%4!8e%58kS`-X;*Z54V>#5V;DsQ9%%E emxsOB5VyV95noh?9W4^K9W4`>T(`D~6PeSF6;Esc delta 215 zcmdnJEAafTK-SIxKX=}ZEHy_Nc{ev4z0$zQ+aBV^2*gZ4%)C9sjpgPXM&9kZk6C6- z1j-#RVFh9~AZFiwyo6(G5>Tw>I>+{!>zraBy_3SZwoeM@-lhT6Az=qmM8$#E_} Date: Fri, 17 Mar 2023 03:06:29 +0100 Subject: [PATCH 261/335] :adhesive_bandage: Mark images as binary --- .gitattributes | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitattributes b/.gitattributes index 1457a771..b5e31c73 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ * text=lf * eol=lf + +*.png binary +*.jpg binary From 27707468710502ffaf7255d9dde29a693539f8a8 Mon Sep 17 00:00:00 2001 From: sigmanificient Date: Fri, 17 Mar 2023 03:06:56 +0100 Subject: [PATCH 262/335] Revert ":art: Format code automatically" This reverts commit a424c528a0d97cd01537623449f1dd86ec519814. --- docs/EpikCord_Logo.png | Bin 1334587 -> 1334607 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/EpikCord_Logo.png b/docs/EpikCord_Logo.png index b6b4fed285b2d54502c3a7c23c0078a80d95e351..c9624c658b78cd945df3df2d0eb4a896c0ba01be 100644 GIT binary patch delta 215 zcmdnJEAafTK-SIxKX=}ZEHy_Nc{ev4z0$zQ+aBV^2*gZ4%)C9sjpgPXM&9kZk6C6- z1j-#RVFh9~AZFiwyo6(G5>Tw>I>+{!>zraBy_3SZwoeM@-lhT6Az=qmM8$#E_}38jQ3Q!lPDil?e8sbZ#nga@S4IJcS4IMdS4IQ3S4ITcoVPLK1e%eD z%yb2Z%yb5a%yb90%ybB~Vuym)2)BaQ2^N5dnO6$8nO6(8D7R363kF2DSHug3?zi5u z3@IA72+a(nAcw8(47aWA4aiToc0CS=K8OE%4!8e%58kS`-X;*Z54V>#5V;DsQ9%%E emxsOB5VyV95noh?9W4^K9W4`>T(`D~6PeSF6;Esc From 3146a1f5737dc7b54283ea004074ca84cab32223 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Mon, 20 Mar 2023 19:02:58 +0000 Subject: [PATCH 263/335] Start work on Sub Command and Sub Command Groups --- EpikCord/client/commands.py | 62 +++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/EpikCord/client/commands.py b/EpikCord/client/commands.py index b806b67b..33c613e4 100644 --- a/EpikCord/client/commands.py +++ b/EpikCord/client/commands.py @@ -486,6 +486,68 @@ def to_dict(self): ] +class SubCommandGroup(BaseApplicationCommandOption): + def __init__( + self, + name: str, + description: str, + *, + name_localizations: Optional[List[Localization]] = None, + description_localizations: Optional[List[Localization]] = None, + ): + super().__init__( + name, + description, + name_localizations=name_localizations, + description_localizations=description_localizations, + type=ApplicationCommandOptionType.SUB_COMMAND_GROUP, + ) + self.commands: List[SubCommand] = [] + + def add_command(self, command: "SubCommand"): + self.commands.append(command) + + def command(self, + name: str, + description: str, + *, + name_localizations: Optional[List[Localization]] = None, + description_localizations: Optional[List[Localization]] = None, + options: Optional[List[BaseApplicationCommandOption]] = None + ): + def add_command(func): + command = SubCommand( + name, + description, + name_localizations=name_localizations, + description_localizations=description_localizations, + options=options + ) + self.add_command(command) + return command + return add_command + +class SubCommand(BaseApplicationCommandOption): + def __init__( + self, + name: str, + description: str, + *, + name_localizations: Optional[List[Localization]] = None, + description_localizations: Optional[List[Localization]] = None, + options: Optional[List[BaseApplicationCommandOption]] = None + ): + super().__init__( + name, + description, + name_localizations=name_localizations, + description_localizations=description_localizations, + type=ApplicationCommandOptionType.SUB_COMMAND, + ) + self.options = options + + + class ClientChatInputCommand(BaseClientCommand): def __init__( self, From fff14a92bf3c8b421afa57d141a16ada2327e922 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 20 Mar 2023 19:04:10 +0000 Subject: [PATCH 264/335] :art: Format code automatically --- EpikCord/client/commands.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/EpikCord/client/commands.py b/EpikCord/client/commands.py index 33c613e4..f752cb93 100644 --- a/EpikCord/client/commands.py +++ b/EpikCord/client/commands.py @@ -506,14 +506,15 @@ def __init__( def add_command(self, command: "SubCommand"): self.commands.append(command) - - def command(self, + + def command( + self, name: str, description: str, *, name_localizations: Optional[List[Localization]] = None, description_localizations: Optional[List[Localization]] = None, - options: Optional[List[BaseApplicationCommandOption]] = None + options: Optional[List[BaseApplicationCommandOption]] = None, ): def add_command(func): command = SubCommand( @@ -521,12 +522,14 @@ def add_command(func): description, name_localizations=name_localizations, description_localizations=description_localizations, - options=options + options=options, ) self.add_command(command) return command + return add_command + class SubCommand(BaseApplicationCommandOption): def __init__( self, @@ -535,7 +538,7 @@ def __init__( *, name_localizations: Optional[List[Localization]] = None, description_localizations: Optional[List[Localization]] = None, - options: Optional[List[BaseApplicationCommandOption]] = None + options: Optional[List[BaseApplicationCommandOption]] = None, ): super().__init__( name, @@ -545,7 +548,6 @@ def __init__( type=ApplicationCommandOptionType.SUB_COMMAND, ) self.options = options - class ClientChatInputCommand(BaseClientCommand): From 9c92636d252d0abb0ac991d6746822b76437cb2a Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Mon, 20 Mar 2023 20:00:15 +0000 Subject: [PATCH 265/335] Import future --- EpikCord/client/commands.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/EpikCord/client/commands.py b/EpikCord/client/commands.py index 33c613e4..2ac40adf 100644 --- a/EpikCord/client/commands.py +++ b/EpikCord/client/commands.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import List, Optional, Union from ..flags import Permissions @@ -504,7 +506,7 @@ def __init__( ) self.commands: List[SubCommand] = [] - def add_command(self, command: "SubCommand"): + def add_command(self, command: SubCommand): self.commands.append(command) def command(self, From ad23ba11ec128187d8a18a40abbcf41d0d009c1c Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Thu, 23 Mar 2023 21:04:21 +0000 Subject: [PATCH 266/335] Add support for sub commands and subcommand groups --- EpikCord/client/client.py | 36 +++++++++++++++++++++++++--- EpikCord/client/commands.py | 46 +++++++++++++++++++++++++++++++----- EpikCord/client/websocket.py | 6 +++-- 3 files changed, 77 insertions(+), 11 deletions(-) diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index 2132d979..9122de9f 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -1,10 +1,13 @@ import asyncio -from typing import Optional +from typing import Optional, List, Union from ..flags import Intents from ..presence import Presence from ..utils import AsyncFunction, OpCode, cleanup_loop, singleton from .http import APIVersion, HTTPClient +from ..flags import Permissions +from ..locales import Localization +from .commands import ClientChatInputCommand, ApplicationCommandType, ApplicationCommandOption, ClientUserCommand, ClientMessageCommand from .websocket import WebSocketClient @@ -56,6 +59,7 @@ def __init__( presence=presence, http=HTTPClient(token, version=version), ) + self.commands: List[Union[ClientChatInputCommand, ClientUserCommand, ClientMessageCommand]] = [] def login(self): loop = asyncio.get_event_loop() @@ -98,6 +102,32 @@ async def change_presence(self, presence: Presence): ) def command( - self, function: AsyncFunction + self, + name: str, + description: str, + *, + guild_ids: Optional[List[int]] = None, + name_localizations: Optional[List[Localization]] = None, + description_localizations: Optional[List[Localization]] = None, + guild_only: Optional[bool] = None, + default_member_permissions: Optional[Permissions] = None, + options: Optional[List[ApplicationCommandOption]] = None, + nsfw: bool = False, ): # TODO: Add in types for command callbacks - ... + def wrapper(func: AsyncFunction): + command = ClientChatInputCommand( + name, + description, + func, + guild_ids=guild_ids, + name_localizations=name_localizations, + description_localizations=description_localizations, + type=ApplicationCommandType.CHAT_INPUT, + guild_only=guild_only, + default_member_permissions=default_member_permissions, + nsfw=nsfw, + options=options, + ) + self.commands.append(command) + return command + return wrapper \ No newline at end of file diff --git a/EpikCord/client/commands.py b/EpikCord/client/commands.py index f3b58e28..d7283681 100644 --- a/EpikCord/client/commands.py +++ b/EpikCord/client/commands.py @@ -496,6 +496,7 @@ def __init__( *, name_localizations: Optional[List[Localization]] = None, description_localizations: Optional[List[Localization]] = None, + parent: Optional[SubCommandGroup] = None, ): super().__init__( name, @@ -504,10 +505,17 @@ def __init__( description_localizations=description_localizations, type=ApplicationCommandOptionType.SUB_COMMAND_GROUP, ) - self.commands: List[SubCommand] = [] - - def add_command(self, command: SubCommand): - self.commands.append(command) + self.commands: List[Union[SubCommand, SubCommandGroup]] = [] + self.parent: Optional[SubCommandGroup] = None + + def add_command(self, command: Union[SubCommand, SubCommandGroup]) -> Union[SubCommand, SubCommandGroup]: + if isinstance(command, SubCommandGroup): + if command.parent and self.parent: + raise ValueError(f"Cannot nest sub commands more than 2 levels deep. {command.parent.name} -> {self.name} -> {command.name}") + command.parent = self + elif isinstance(command, SubCommand): + self.commands.append(command) + return command def command( self, @@ -522,21 +530,43 @@ def add_command(func): command = SubCommand( name, description, + func, name_localizations=name_localizations, description_localizations=description_localizations, options=options, ) self.add_command(command) return command - return add_command + def group( + self, + name: str, + description: str, + *, + name_localizations: Optional[List[Localization]] = None, + description_localizations: Optional[List[Localization]] = None, + ) -> SubCommandGroup: + group = SubCommandGroup( + name, + description, + name_localizations=name_localizations, + description_localizations=description_localizations, + ) + self.add_command(group) + return group + + def to_dict(self): + payload = super().to_dict() + payload["options"] = [command.to_dict() for command in self.commands] + return payload class SubCommand(BaseApplicationCommandOption): def __init__( self, name: str, description: str, + callback: AsyncFunction, *, name_localizations: Optional[List[Localization]] = None, description_localizations: Optional[List[Localization]] = None, @@ -549,7 +579,11 @@ def __init__( description_localizations=description_localizations, type=ApplicationCommandOptionType.SUB_COMMAND, ) + for option in (options or []): + if isinstance(option, (SubCommand, SubCommandGroup)): + raise TypeError("Subcommands cannot have subcommands or sub command groups") self.options = options + self.callback = callback class ClientChatInputCommand(BaseClientCommand): @@ -565,8 +599,8 @@ def __init__( type: ApplicationCommandType = ApplicationCommandType.CHAT_INPUT, guild_only: Optional[bool] = None, default_member_permissions: Optional[Permissions] = None, - nsfw: bool = False, options: Optional[List[ApplicationCommandOption]] = None, + nsfw: bool = False, ): super().__init__( name, diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 4741a6ce..5955f29a 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -135,7 +135,7 @@ async def _ready(self, data: ReadyData): async def _resumed(self, _: Any): logger.info("Resumed session %s", self.client.session_id) - await self.dispatch("resumed", self.client.session_id) + await self.dispatch("resumed") async def identify(self): payload: IdentifyCommand = { @@ -298,7 +298,7 @@ async def receive(self, *args, **kwargs): ) async def close(self, *, code: int = 4000, message: bytes = b"") -> bool: - logger.debug("Closing websocket with code %s", code) + logger.debug("Closing websocket with code %s. This is triggered by the library.", code) return await super().close(code=code, message=message) @@ -394,3 +394,5 @@ async def handle_close(self): if ch_ins.resumable: await self.connect(resume=True) + elif close_code in (1000, 1001): + await self.connect(resume=False) From 5085da914b82521e70f4fbb62d094452592db0a5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 23 Mar 2023 21:05:29 +0000 Subject: [PATCH 267/335] :art: Format code automatically --- EpikCord/client/client.py | 50 +++++++++++++++++++++--------------- EpikCord/client/commands.py | 28 ++++++++++++-------- EpikCord/client/websocket.py | 5 +++- 3 files changed, 52 insertions(+), 31 deletions(-) diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index 9122de9f..4d65cb5e 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -1,13 +1,18 @@ import asyncio -from typing import Optional, List, Union +from typing import List, Optional, Union -from ..flags import Intents +from ..flags import Intents, Permissions +from ..locales import Localization from ..presence import Presence from ..utils import AsyncFunction, OpCode, cleanup_loop, singleton +from .commands import ( + ApplicationCommandOption, + ApplicationCommandType, + ClientChatInputCommand, + ClientMessageCommand, + ClientUserCommand, +) from .http import APIVersion, HTTPClient -from ..flags import Permissions -from ..locales import Localization -from .commands import ClientChatInputCommand, ApplicationCommandType, ApplicationCommandOption, ClientUserCommand, ClientMessageCommand from .websocket import WebSocketClient @@ -59,7 +64,11 @@ def __init__( presence=presence, http=HTTPClient(token, version=version), ) - self.commands: List[Union[ClientChatInputCommand, ClientUserCommand, ClientMessageCommand]] = [] + self.commands: List[ + Union[ + ClientChatInputCommand, ClientUserCommand, ClientMessageCommand + ] + ] = [] def login(self): loop = asyncio.get_event_loop() @@ -111,23 +120,24 @@ def command( description_localizations: Optional[List[Localization]] = None, guild_only: Optional[bool] = None, default_member_permissions: Optional[Permissions] = None, - options: Optional[List[ApplicationCommandOption]] = None, + options: Optional[List[ApplicationCommandOption]] = None, nsfw: bool = False, ): # TODO: Add in types for command callbacks def wrapper(func: AsyncFunction): command = ClientChatInputCommand( - name, - description, - func, - guild_ids=guild_ids, - name_localizations=name_localizations, - description_localizations=description_localizations, - type=ApplicationCommandType.CHAT_INPUT, - guild_only=guild_only, - default_member_permissions=default_member_permissions, - nsfw=nsfw, - options=options, - ) + name, + description, + func, + guild_ids=guild_ids, + name_localizations=name_localizations, + description_localizations=description_localizations, + type=ApplicationCommandType.CHAT_INPUT, + guild_only=guild_only, + default_member_permissions=default_member_permissions, + nsfw=nsfw, + options=options, + ) self.commands.append(command) return command - return wrapper \ No newline at end of file + + return wrapper diff --git a/EpikCord/client/commands.py b/EpikCord/client/commands.py index d7283681..d4d0d37a 100644 --- a/EpikCord/client/commands.py +++ b/EpikCord/client/commands.py @@ -508,10 +508,14 @@ def __init__( self.commands: List[Union[SubCommand, SubCommandGroup]] = [] self.parent: Optional[SubCommandGroup] = None - def add_command(self, command: Union[SubCommand, SubCommandGroup]) -> Union[SubCommand, SubCommandGroup]: + def add_command( + self, command: Union[SubCommand, SubCommandGroup] + ) -> Union[SubCommand, SubCommandGroup]: if isinstance(command, SubCommandGroup): if command.parent and self.parent: - raise ValueError(f"Cannot nest sub commands more than 2 levels deep. {command.parent.name} -> {self.name} -> {command.name}") + raise ValueError( + f"Cannot nest sub commands more than 2 levels deep. {command.parent.name} -> {self.name} -> {command.name}" + ) command.parent = self elif isinstance(command, SubCommand): self.commands.append(command) @@ -537,15 +541,16 @@ def add_command(func): ) self.add_command(command) return command + return add_command def group( - self, - name: str, - description: str, - *, - name_localizations: Optional[List[Localization]] = None, - description_localizations: Optional[List[Localization]] = None, + self, + name: str, + description: str, + *, + name_localizations: Optional[List[Localization]] = None, + description_localizations: Optional[List[Localization]] = None, ) -> SubCommandGroup: group = SubCommandGroup( name, @@ -561,6 +566,7 @@ def to_dict(self): payload["options"] = [command.to_dict() for command in self.commands] return payload + class SubCommand(BaseApplicationCommandOption): def __init__( self, @@ -579,9 +585,11 @@ def __init__( description_localizations=description_localizations, type=ApplicationCommandOptionType.SUB_COMMAND, ) - for option in (options or []): + for option in options or []: if isinstance(option, (SubCommand, SubCommandGroup)): - raise TypeError("Subcommands cannot have subcommands or sub command groups") + raise TypeError( + "Subcommands cannot have subcommands or sub command groups" + ) self.options = options self.callback = callback diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 5955f29a..c8b3bdf7 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -298,7 +298,10 @@ async def receive(self, *args, **kwargs): ) async def close(self, *, code: int = 4000, message: bytes = b"") -> bool: - logger.debug("Closing websocket with code %s. This is triggered by the library.", code) + logger.debug( + "Closing websocket with code %s. This is triggered by the library.", + code, + ) return await super().close(code=code, message=message) From ca59fd361b70c72f05db13460ff802593f194867 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Thu, 23 Mar 2023 21:33:33 +0000 Subject: [PATCH 268/335] Better handle 1000 and 1001 --- EpikCord/client/websocket.py | 5 +++-- EpikCord/utils/enums.py | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 5955f29a..a86513ae 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -376,6 +376,9 @@ async def handle_close(self): logger.critical("Websocket closed with code %s", close_code) + if close_code in (GatewayCloseCode.NORMAL_CLOSURE, GatewayCloseCode.ABNORMAL_CLOSURE): + await self.connect(resume=False) + try: gce_code = GatewayCloseCode(close_code) ch_ins = close_dispatcher[gce_code] @@ -394,5 +397,3 @@ async def handle_close(self): if ch_ins.resumable: await self.connect(resume=True) - elif close_code in (1000, 1001): - await self.connect(resume=False) diff --git a/EpikCord/utils/enums.py b/EpikCord/utils/enums.py index a3bad56f..8d525e8f 100644 --- a/EpikCord/utils/enums.py +++ b/EpikCord/utils/enums.py @@ -24,6 +24,8 @@ class OpCode(IntEnum): class GatewayCloseCode(IntEnum): """The close codes used in the Discord Gateway.""" + NORMAL_CLOSURE = 1000 + GOING_AWAY = 1001 ABNORMAL_CLOSURE = 1006 UNKNOWN_ERROR = 4000 UNKNOWN_OPCODE = 4001 From 339aacd4d7190136e4734974c20f8e07f23cd412 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 23 Mar 2023 21:34:44 +0000 Subject: [PATCH 269/335] :art: Format code automatically --- EpikCord/client/websocket.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 8b254c0d..83fc8b5b 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -379,7 +379,10 @@ async def handle_close(self): logger.critical("Websocket closed with code %s", close_code) - if close_code in (GatewayCloseCode.NORMAL_CLOSURE, GatewayCloseCode.ABNORMAL_CLOSURE): + if close_code in ( + GatewayCloseCode.NORMAL_CLOSURE, + GatewayCloseCode.ABNORMAL_CLOSURE, + ): await self.connect(resume=False) try: From b8a683db56a6215fc54903a01b6b2637d5407891 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Fri, 24 Mar 2023 21:42:36 +0000 Subject: [PATCH 270/335] Add ReceivedOption --- EpikCord/client/http.py | 1 - EpikCord/interaction.py | 35 ++++++++++++++++++++++++++++++++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 367b732e..72cfed7e 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -353,7 +353,6 @@ def setup_request_kwargs( json["attachments"].append(attachment) form.add_field("payload_json", self.session.json_serialize(json)) - return_kwargs["data"] = form return return_kwargs diff --git a/EpikCord/interaction.py b/EpikCord/interaction.py index 28607dd4..040fd0a3 100644 --- a/EpikCord/interaction.py +++ b/EpikCord/interaction.py @@ -1,6 +1,11 @@ -from typing import Union +from typing import Union, List, Optional -from discord_typings import ResolvedInteractionDataData +from discord_typings import ( + ResolvedInteractionDataData, + ApplicationCommandOptionInteractionData, + UserData, + GuildMemberData +) from .client import Client from .file import Attachment @@ -17,6 +22,7 @@ from .user import User from .utils import ( ApplicationCommandType, + ApplicationCommandOptionType, InteractionType, Locale, instance_or_none, @@ -92,10 +98,25 @@ def __init__(self, client: Client, data: ApplicationCommandInteractionData): ) +class ReceivedOption: + def __init__(self, data: ApplicationCommandOptionInteractionData): + self.name = data["name"] + self.type: ApplicationCommandOptionType = ApplicationCommandOptionType( + data["type"] + ) + self.value = data.get("value") + self.options: List[ReceivedOption] = [ + ReceivedOption(option) for option in data.get("options", []) + ] + self.focused: Optional[bool] = data.get("focused") + + class ChatInputCommandInteraction(BaseApplicationCommandInteraction): def __init__(self, client: Client, data: ChatInputInteractionData): super().__init__(client, data) - # OPTIONS + self.options: List[ReceivedOption] = [ + ReceivedOption(option) for option in self.data.get("options", []) + ] class BaseContextMenuInteraction(BaseApplicationCommandInteraction): @@ -112,3 +133,11 @@ def __init__( MessageContextMenuInteractionDataData, ] self.target_id = int(self.data["target_id"]) + +class UserContextMenuInteraction(BaseContextMenuInteraction): + def __init__(self, client: Client, data: UserContextMenuInteractionData): + super().__init__(client, data) + self.data: UserContextMenuInteractionDataData + if self.resolved: + user: Optional[User] = self.resolved.users.get(self.target_id) + member: Optional[GuildMember] = self.resolved.members.get(self.target_id) \ No newline at end of file From 94879cce4568e3c6d6b495dcfdc9ef56b398b2e2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 24 Mar 2023 21:44:16 +0000 Subject: [PATCH 271/335] :art: Format code automatically --- EpikCord/interaction.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/EpikCord/interaction.py b/EpikCord/interaction.py index 040fd0a3..ca128e60 100644 --- a/EpikCord/interaction.py +++ b/EpikCord/interaction.py @@ -1,10 +1,10 @@ -from typing import Union, List, Optional +from typing import List, Optional, Union from discord_typings import ( - ResolvedInteractionDataData, ApplicationCommandOptionInteractionData, + GuildMemberData, + ResolvedInteractionDataData, UserData, - GuildMemberData ) from .client import Client @@ -21,8 +21,8 @@ ) from .user import User from .utils import ( - ApplicationCommandType, ApplicationCommandOptionType, + ApplicationCommandType, InteractionType, Locale, instance_or_none, @@ -134,10 +134,13 @@ def __init__( ] self.target_id = int(self.data["target_id"]) + class UserContextMenuInteraction(BaseContextMenuInteraction): def __init__(self, client: Client, data: UserContextMenuInteractionData): super().__init__(client, data) self.data: UserContextMenuInteractionDataData if self.resolved: user: Optional[User] = self.resolved.users.get(self.target_id) - member: Optional[GuildMember] = self.resolved.members.get(self.target_id) \ No newline at end of file + member: Optional[GuildMember] = self.resolved.members.get( + self.target_id + ) From cd0bcfc166513ffbcd75e05704663d405c140317 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Tue, 28 Mar 2023 20:55:07 +0100 Subject: [PATCH 272/335] Move Locale to Localization.py --- EpikCord/interaction.py | 7 ++++--- EpikCord/locales.py | 36 +++++++++++++++++++++++++++++++++--- EpikCord/utils/__init__.py | 2 -- EpikCord/utils/enums.py | 35 ----------------------------------- 4 files changed, 37 insertions(+), 43 deletions(-) diff --git a/EpikCord/interaction.py b/EpikCord/interaction.py index 040fd0a3..d9fb1cb2 100644 --- a/EpikCord/interaction.py +++ b/EpikCord/interaction.py @@ -3,8 +3,6 @@ from discord_typings import ( ResolvedInteractionDataData, ApplicationCommandOptionInteractionData, - UserData, - GuildMemberData ) from .client import Client @@ -24,11 +22,12 @@ ApplicationCommandType, ApplicationCommandOptionType, InteractionType, - Locale, instance_or_none, int_or_none, ) +from .locales import Locale + class BaseInteraction: def __init__(self, client: Client, data: InteractionData): @@ -97,6 +96,8 @@ def __init__(self, client: Client, data: ApplicationCommandInteractionData): ResolvedInteractionData, data.get("resolved") ) + self._resolved = data.get("resolved") + class ReceivedOption: def __init__(self, data: ApplicationCommandOptionInteractionData): diff --git a/EpikCord/locales.py b/EpikCord/locales.py index d5e5fb80..36ac575a 100644 --- a/EpikCord/locales.py +++ b/EpikCord/locales.py @@ -1,8 +1,38 @@ +from enum import Enum from typing import Dict -from .utils import Locale - - +class Locale(Enum): + INDONESIAN = "id" + DANISH = "da" + GERMAN = "de" + ENGLISH_UK = "en-GB" + ENGLISH_US = "en-US" + SPANISH = "es-ES" + FRENCH = "fr" + CROATIAN = "hr" + ITALIAN = "it" + LITHUANIAN = "lt" + HUNGARIAN = "hu" + DUTCH = "nl" + NORWEGIAN = "no" + POLISH = "pl" + PORTUGUESE_BRAZILIAN = "pt-BR" + ROMANIAN_ROMANIA = "ro" + FINNISH = "fi" + SWEDISH = "sv-SE" + VIETNAMESE = "vi" + TURKISH = "tr" + CZECH = "cs" + GREEK = "el" + BULGARIAN = "bg" + RUSSIAN = "ru" + UKRAINIAN = "uk" + HINDI = "hi" + THAI = "th" + CHINESE_CHINA = "zh-CN" + JAPANESE = "ja" + CHINESE_TAIWAN = "zh-TW" + KOREAN = "ko" class Localization: def __init__(self, locale: Locale, value: str): self.locale = locale diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py index 491fa2c1..11f88eb6 100644 --- a/EpikCord/utils/__init__.py +++ b/EpikCord/utils/__init__.py @@ -6,7 +6,6 @@ HTTPCodes, InteractionType, JSONErrorCodes, - Locale, OpCode, PremiumType, StatusCode, @@ -51,7 +50,6 @@ "ApplicationCommandType", "TeamMembershipState", "ApplicationCommandOptionType", - "Locale", "PremiumType", "add_file", "cancel_tasks", diff --git a/EpikCord/utils/enums.py b/EpikCord/utils/enums.py index 8d525e8f..d00af202 100644 --- a/EpikCord/utils/enums.py +++ b/EpikCord/utils/enums.py @@ -354,41 +354,6 @@ class TeamMembershipState(IntEnum): INVITED = 1 ACCEPTED = 2 - -class Locale(Enum): - INDONESIAN = "id" - DANISH = "da" - GERMAN = "de" - ENGLISH_UK = "en-GB" - ENGLISH_US = "en-US" - SPANISH = "es-ES" - FRENCH = "fr" - CROATIAN = "hr" - ITALIAN = "it" - LITHUANIAN = "lt" - HUNGARIAN = "hu" - DUTCH = "nl" - NORWEGIAN = "no" - POLISH = "pl" - PORTUGUESE_BRAZILIAN = "pt-BR" - ROMANIAN_ROMANIA = "ro" - FINNISH = "fi" - SWEDISH = "sv-SE" - VIETNAMESE = "vi" - TURKISH = "tr" - CZECH = "cs" - GREEK = "el" - BULGARIAN = "bg" - RUSSIAN = "ru" - UKRAINIAN = "uk" - HINDI = "hi" - THAI = "th" - CHINESE_CHINA = "zh-CN" - JAPANESE = "ja" - CHINESE_TAIWAN = "zh-TW" - KOREAN = "ko" - - class PremiumType(IntEnum): NONE = 0 NITRO_CLASSIC = 1 From 3470cfda3a73dadb052c69ca198b5f1be439c73d Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sat, 1 Apr 2023 14:40:02 +0100 Subject: [PATCH 273/335] Log when closing --- EpikCord/client/client.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index 4d65cb5e..06933e04 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -1,5 +1,6 @@ import asyncio from typing import List, Optional, Union +from logging import getLogger from ..flags import Intents, Permissions from ..locales import Localization @@ -15,6 +16,7 @@ from .http import APIVersion, HTTPClient from .websocket import WebSocketClient +logger = getLogger("EpikCord.client") @singleton class TokenStore: @@ -77,6 +79,7 @@ async def runner(): try: await self.connect() finally: + logger.critical("Final clause triggered.") if not self.http.session.closed: await self.http.close() @@ -95,8 +98,9 @@ def stop_loop_on_completion(f: asyncio.Future): cleanup_loop(loop) async def close(self): + logger.critical("Close function called") if not self.http.session.closed: - await self.http.session.close() + await self.http.close() if self.ws and self.ws.closed: await self.ws.close() From 6393eb550f689f569706f81857de6dbc0d9a2a5c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 1 Apr 2023 13:44:09 +0000 Subject: [PATCH 274/335] :art: Format code automatically --- EpikCord/client/client.py | 3 ++- EpikCord/interaction.py | 3 +-- EpikCord/locales.py | 3 +++ EpikCord/utils/enums.py | 1 + 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index 06933e04..48f922e4 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -1,6 +1,6 @@ import asyncio -from typing import List, Optional, Union from logging import getLogger +from typing import List, Optional, Union from ..flags import Intents, Permissions from ..locales import Localization @@ -18,6 +18,7 @@ logger = getLogger("EpikCord.client") + @singleton class TokenStore: def __init__(self, token: str): diff --git a/EpikCord/interaction.py b/EpikCord/interaction.py index d4985673..9cfdc971 100644 --- a/EpikCord/interaction.py +++ b/EpikCord/interaction.py @@ -9,6 +9,7 @@ from .file import Attachment from .flags import Permissions from .guild import GuildMember, Role +from .locales import Locale from .types import ( ChatInputInteractionData, InteractionData, @@ -26,8 +27,6 @@ int_or_none, ) -from .locales import Locale - class BaseInteraction: def __init__(self, client: Client, data: InteractionData): diff --git a/EpikCord/locales.py b/EpikCord/locales.py index 36ac575a..5ca52df3 100644 --- a/EpikCord/locales.py +++ b/EpikCord/locales.py @@ -1,6 +1,7 @@ from enum import Enum from typing import Dict + class Locale(Enum): INDONESIAN = "id" DANISH = "da" @@ -33,6 +34,8 @@ class Locale(Enum): JAPANESE = "ja" CHINESE_TAIWAN = "zh-TW" KOREAN = "ko" + + class Localization: def __init__(self, locale: Locale, value: str): self.locale = locale diff --git a/EpikCord/utils/enums.py b/EpikCord/utils/enums.py index d00af202..7513e5d9 100644 --- a/EpikCord/utils/enums.py +++ b/EpikCord/utils/enums.py @@ -354,6 +354,7 @@ class TeamMembershipState(IntEnum): INVITED = 1 ACCEPTED = 2 + class PremiumType(IntEnum): NONE = 0 NITRO_CLASSIC = 1 From f6240aba9148dbbe4860ed266c4fdda607ab6723 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Fri, 7 Apr 2023 00:16:00 +0100 Subject: [PATCH 275/335] Remove duplicate close functions --- EpikCord/client/client.py | 10 ++++++---- EpikCord/client/commands.py | 3 ++- EpikCord/client/http.py | 4 ---- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index 06933e04..73ff71b2 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -18,6 +18,7 @@ logger = getLogger("EpikCord.client") + @singleton class TokenStore: def __init__(self, token: str): @@ -81,7 +82,7 @@ async def runner(): finally: logger.critical("Final clause triggered.") if not self.http.session.closed: - await self.http.close() + await self.http.session.close() def stop_loop_on_completion(f: asyncio.Future): loop.stop() @@ -98,11 +99,12 @@ def stop_loop_on_completion(f: asyncio.Future): cleanup_loop(loop) async def close(self): - logger.critical("Close function called") + logger.critical("Client.close called") + if not self.http.session.closed: - await self.http.close() + await self.http.session.close() - if self.ws and self.ws.closed: + if self.ws and not self.ws.closed: await self.ws.close() self.rate_limiter.reset.cancel() diff --git a/EpikCord/client/commands.py b/EpikCord/client/commands.py index d4d0d37a..0e035ecf 100644 --- a/EpikCord/client/commands.py +++ b/EpikCord/client/commands.py @@ -514,7 +514,8 @@ def add_command( if isinstance(command, SubCommandGroup): if command.parent and self.parent: raise ValueError( - f"Cannot nest sub commands more than 2 levels deep. {command.parent.name} -> {self.name} -> {command.name}" + "Cannot nest sub commands more than 2 levels deep." + f"{command.parent.name} -> {self.name} -> {command.name}" ) command.parent = self elif isinstance(command, SubCommand): diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 72cfed7e..4c7ba6f9 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -357,10 +357,6 @@ def setup_request_kwargs( return return_kwargs - async def close(self) -> None: - """Close the session.""" - await self.session.close() - async def get_gateway(self) -> str: """Get the URL used for connecting to the Gateway. From 3ad5652d9e34595e346c4bedc933f85107c0a7d0 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Fri, 7 Apr 2023 00:46:53 +0100 Subject: [PATCH 276/335] Move gateway types to types/gateway.py --- EpikCord/client/client.py | 3 +- EpikCord/client/websocket.py | 3 +- EpikCord/client/ws_close_handler.py | 2 +- EpikCord/types/__init__.py | 22 ++++++ EpikCord/types/gateway.py | 105 ++++++++++++++++++++++++++++ EpikCord/utils/__init__.py | 8 --- EpikCord/utils/enums.py | 73 +------------------ EpikCord/utils/types.py | 2 +- 8 files changed, 134 insertions(+), 84 deletions(-) create mode 100644 EpikCord/types/gateway.py diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index f132e3f5..931b7d90 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -5,7 +5,8 @@ from ..flags import Intents, Permissions from ..locales import Localization from ..presence import Presence -from ..utils import AsyncFunction, OpCode, cleanup_loop, singleton +from ..types import OpCode +from ..utils import AsyncFunction, cleanup_loop, singleton from .commands import ( ApplicationCommandOption, ApplicationCommandType, diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 83fc8b5b..ea2db417 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -16,7 +16,8 @@ from ..exceptions import ClosedWebSocketConnection from ..flags import Intents from ..presence import Presence -from ..utils import AsyncFunction, GatewayCloseCode, IdentifyCommand, OpCode +from ..utils import AsyncFunction +from ..types import GatewayCloseCode, IdentifyCommand, OpCode from .rate_limit_tools import GatewayRateLimiter from .ws_close_handler import ( CloseHandlerLog, diff --git a/EpikCord/client/ws_close_handler.py b/EpikCord/client/ws_close_handler.py index 3df666a1..b1e01261 100644 --- a/EpikCord/client/ws_close_handler.py +++ b/EpikCord/client/ws_close_handler.py @@ -9,7 +9,7 @@ InvalidToken, ShardingRequired, ) -from ..utils import GatewayCloseCode +from ..types import GatewayCloseCode logger = logging.getLogger("EpikCord.websocket") diff --git a/EpikCord/types/__init__.py b/EpikCord/types/__init__.py index aa25269b..fcc302aa 100644 --- a/EpikCord/types/__init__.py +++ b/EpikCord/types/__init__.py @@ -29,6 +29,18 @@ UserOptionData, ) +from .gateway import ( + OpCode, + GatewayCloseCode, + IdentifyCommand, + IdentifyConnectionProperties, + IdentifyData, + SendingAttachmentData, + VoiceCloseCode, + VoiceOpCode, + UpdatePresenceData +) + __all__ = ( "ChatInputInteractionDataData", "MessageContextMenuInteractionDataData", @@ -58,4 +70,14 @@ "NumberOptionData", "AttachmentOptionData", "ApplicationCommandOptionData", + "OpCode", + "GatewayCloseCode", + "IdentifyCommand", + "IdentifyConnectionProperties", + "IdentifyData", + "SendingAttachmentData", + "VoiceCloseCode", + "VoiceOpCode", + "UpdatePresenceData", + ) diff --git a/EpikCord/types/gateway.py b/EpikCord/types/gateway.py new file mode 100644 index 00000000..6a91a8dc --- /dev/null +++ b/EpikCord/types/gateway.py @@ -0,0 +1,105 @@ +from enum import IntEnum +from typing import TypedDict + +from discord_typings import IdentifyConnectionProperties +from typing_extensions import Literal, NotRequired + +from ..presence import UpdatePresenceData + + +class OpCode(IntEnum): + """The opcodes used in the Discord Gateway.""" + + DISPATCH = 0 + HEARTBEAT = 1 + IDENTIFY = 2 + PRESENCE_UPDATE = 3 + VOICE_STATE_UPDATE = 4 + RESUME = 6 + RECONNECT = 7 + REQUEST_GUILD_MEMBERS = 8 + INVALID_SESSION = 9 + HELLO = 10 + HEARTBEAT_ACK = 11 + + +class GatewayCloseCode(IntEnum): + """The close codes used in the Discord Gateway.""" + + NORMAL_CLOSURE = 1000 + GOING_AWAY = 1001 + ABNORMAL_CLOSURE = 1006 + UNKNOWN_ERROR = 4000 + UNKNOWN_OPCODE = 4001 + DECODE_ERROR = 4002 + NOT_AUTHENTICATED = 4003 + AUTHENTICATION_FAILED = 4004 + ALREADY_AUTHENTICATED = 4005 + INVALID_SEQUENCE = 4007 + RATE_LIMITED = 4008 + SESSION_TIMED_OUT = 4009 + INVALID_SHARD = 4010 + SHARDING_REQUIRED = 4011 + INVALID_API_VERSION = 4012 + INVALID_INTENTS = 4013 + DISALLOWED_INTENTS = 4014 + + +class VoiceOpCode(IntEnum): + """The opcodes used in the Discord Voice WebSocket connection.""" + + IDENTIFY = 0 + SELECT_PROTOCOL = 1 + READY = 2 + HEARTBEAT = 3 + SESSION_DESCRIPTION = 4 + SPEAKING = 5 + HEARTBEAT_ACK = 6 + RESUME = 7 + HELLO = 8 + RESUMED = 9 + CLIENT_DISCONNECT = 13 + + +class VoiceCloseCode(IntEnum): + """The close codes used in the Discord Voice WebSocket connection.""" + + UNKNOWN_OPCODE = 4001 + FAILED_TO_DECODE_PAYLOAD = 4002 + NOT_AUTHENTICATED = 4003 + AUTHENTICATION_FAILED = 4004 + ALREADY_AUTHENTICATED = 4005 + SESSION_NO_LONGER_VALID = 4006 + SESSION_TIMEOUT = 4009 + SERVER_NOT_FOUND = 4011 + UNKNOWN_PROTOCOL = 4012 + DISCONNECTED = 4014 + VOICE_SERVER_CRASHED = 4015 + UNKNOWN_ENCRYPTION_MODE = 4016 + + +class IdentifyData(TypedDict): + """The data used in the identify payload.""" + + token: str + intents: int + properties: IdentifyConnectionProperties + compress: bool + large_threshold: int + presence: NotRequired[UpdatePresenceData] + + +class IdentifyCommand(TypedDict): + """The data used to identify with the gateway.""" + + op: Literal[OpCode.IDENTIFY] + d: IdentifyData + + +class SendingAttachmentData(TypedDict): + """The data used to send an attachment.""" + + id: int + filename: str + description: NotRequired[str] + ephemeral: NotRequired[bool] diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py index 11f88eb6..a1417ef7 100644 --- a/EpikCord/utils/__init__.py +++ b/EpikCord/utils/__init__.py @@ -2,16 +2,12 @@ ApplicationCommandOptionType, ApplicationCommandType, ChannelType, - GatewayCloseCode, HTTPCodes, InteractionType, JSONErrorCodes, - OpCode, PremiumType, StatusCode, TeamMembershipState, - VoiceCloseCode, - VoiceOpCode, ) from .loose import ( add_file, @@ -38,13 +34,9 @@ "HTTPCodes", "JSONErrorCodes", "AsyncFunction", - "GatewayCloseCode", - "OpCode", "IdentifyData", "IdentifyCommand", "SendingAttachmentData", - "VoiceCloseCode", - "VoiceOpCode", "StatusCode", "InteractionType", "ApplicationCommandType", diff --git a/EpikCord/utils/enums.py b/EpikCord/utils/enums.py index 7513e5d9..76cd8411 100644 --- a/EpikCord/utils/enums.py +++ b/EpikCord/utils/enums.py @@ -1,81 +1,10 @@ from __future__ import annotations -from enum import Enum, IntEnum +from enum import IntEnum from logging import getLogger from typing import Final -class OpCode(IntEnum): - """The opcodes used in the Discord Gateway.""" - - DISPATCH = 0 - HEARTBEAT = 1 - IDENTIFY = 2 - PRESENCE_UPDATE = 3 - VOICE_STATE_UPDATE = 4 - RESUME = 6 - RECONNECT = 7 - REQUEST_GUILD_MEMBERS = 8 - INVALID_SESSION = 9 - HELLO = 10 - HEARTBEAT_ACK = 11 - - -class GatewayCloseCode(IntEnum): - """The close codes used in the Discord Gateway.""" - - NORMAL_CLOSURE = 1000 - GOING_AWAY = 1001 - ABNORMAL_CLOSURE = 1006 - UNKNOWN_ERROR = 4000 - UNKNOWN_OPCODE = 4001 - DECODE_ERROR = 4002 - NOT_AUTHENTICATED = 4003 - AUTHENTICATION_FAILED = 4004 - ALREADY_AUTHENTICATED = 4005 - INVALID_SEQUENCE = 4007 - RATE_LIMITED = 4008 - SESSION_TIMED_OUT = 4009 - INVALID_SHARD = 4010 - SHARDING_REQUIRED = 4011 - INVALID_API_VERSION = 4012 - INVALID_INTENTS = 4013 - DISALLOWED_INTENTS = 4014 - - -class VoiceOpCode(IntEnum): - """The opcodes used in the Discord Voice WebSocket connection.""" - - IDENTIFY = 0 - SELECT_PROTOCOL = 1 - READY = 2 - HEARTBEAT = 3 - SESSION_DESCRIPTION = 4 - SPEAKING = 5 - HEARTBEAT_ACK = 6 - RESUME = 7 - HELLO = 8 - RESUMED = 9 - CLIENT_DISCONNECT = 13 - - -class VoiceCloseCode(IntEnum): - """The close codes used in the Discord Voice WebSocket connection.""" - - UNKNOWN_OPCODE = 4001 - FAILED_TO_DECODE_PAYLOAD = 4002 - NOT_AUTHENTICATED = 4003 - AUTHENTICATION_FAILED = 4004 - ALREADY_AUTHENTICATED = 4005 - SESSION_NO_LONGER_VALID = 4006 - SESSION_TIMEOUT = 4009 - SERVER_NOT_FOUND = 4011 - UNKNOWN_PROTOCOL = 4012 - DISCONNECTED = 4014 - VOICE_SERVER_CRASHED = 4015 - UNKNOWN_ENCRYPTION_MODE = 4016 - - class StatusCode(IntEnum): pass diff --git a/EpikCord/utils/types.py b/EpikCord/utils/types.py index 791bec02..e12fdb92 100644 --- a/EpikCord/utils/types.py +++ b/EpikCord/utils/types.py @@ -4,7 +4,7 @@ from typing_extensions import Literal, NotRequired from ..presence import UpdatePresenceData -from .enums import OpCode +from ..types import OpCode AsyncFunction = Callable[..., Coroutine[Any, Any, Any]] From 58df116d24911a2aa14245ea6a552d847d3d2724 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 6 Apr 2023 23:47:45 +0000 Subject: [PATCH 277/335] :art: Format code automatically --- EpikCord/client/websocket.py | 2 +- EpikCord/types/__init__.py | 24 +++++++++++------------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index ea2db417..aa10bbe1 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -16,8 +16,8 @@ from ..exceptions import ClosedWebSocketConnection from ..flags import Intents from ..presence import Presence -from ..utils import AsyncFunction from ..types import GatewayCloseCode, IdentifyCommand, OpCode +from ..utils import AsyncFunction from .rate_limit_tools import GatewayRateLimiter from .ws_close_handler import ( CloseHandlerLog, diff --git a/EpikCord/types/__init__.py b/EpikCord/types/__init__.py index fcc302aa..ba007df8 100644 --- a/EpikCord/types/__init__.py +++ b/EpikCord/types/__init__.py @@ -1,3 +1,14 @@ +from .gateway import ( + GatewayCloseCode, + IdentifyCommand, + IdentifyConnectionProperties, + IdentifyData, + OpCode, + SendingAttachmentData, + UpdatePresenceData, + VoiceCloseCode, + VoiceOpCode, +) from .interactions import ( ApplicationCommandChoiceData, ApplicationCommandOptionData, @@ -29,18 +40,6 @@ UserOptionData, ) -from .gateway import ( - OpCode, - GatewayCloseCode, - IdentifyCommand, - IdentifyConnectionProperties, - IdentifyData, - SendingAttachmentData, - VoiceCloseCode, - VoiceOpCode, - UpdatePresenceData -) - __all__ = ( "ChatInputInteractionDataData", "MessageContextMenuInteractionDataData", @@ -79,5 +78,4 @@ "VoiceCloseCode", "VoiceOpCode", "UpdatePresenceData", - ) From d8f4e1acf08e9b328b442c1a758528f80fc899fd Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Fri, 7 Apr 2023 00:48:57 +0100 Subject: [PATCH 278/335] Import Locale from locales.py --- EpikCord/user.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/EpikCord/user.py b/EpikCord/user.py index b33e9d1d..75e5212f 100644 --- a/EpikCord/user.py +++ b/EpikCord/user.py @@ -2,7 +2,8 @@ from .client import Client from .flags import UserFlags -from .utils import Locale, PremiumType, instance_or_none, int_or_none +from .locales import Locale +from .utils import PremiumType, instance_or_none, int_or_none class User: From 1ffe7cde9333a07993d8626e002bb402b1326ec6 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Fri, 7 Apr 2023 00:53:25 +0100 Subject: [PATCH 279/335] Fix linter --- EpikCord/interaction.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/EpikCord/interaction.py b/EpikCord/interaction.py index 9cfdc971..e918a248 100644 --- a/EpikCord/interaction.py +++ b/EpikCord/interaction.py @@ -140,7 +140,8 @@ def __init__(self, client: Client, data: UserContextMenuInteractionData): super().__init__(client, data) self.data: UserContextMenuInteractionDataData if self.resolved: - user: Optional[User] = self.resolved.users.get(self.target_id) - member: Optional[GuildMember] = self.resolved.members.get( - self.target_id - ) + # user: Optional[User] = self.resolved.users.get(self.target_id) + # member: Optional[GuildMember] = self.resolved.members.get( + # self.target_id + # ) + ... From 1c5e727a54c4014886bca99ba77e377359557ae7 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Fri, 7 Apr 2023 13:10:51 +0100 Subject: [PATCH 280/335] Change author email --- EpikCord/client/client.py | 3 +- EpikCord/client/websocket.py | 70 ++++++++++++++++++++++-------------- setup.cfg | 2 +- 3 files changed, 45 insertions(+), 30 deletions(-) diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index 931b7d90..8cc0ae99 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -82,8 +82,7 @@ async def runner(): await self.connect() finally: logger.critical("Final clause triggered.") - if not self.http.session.closed: - await self.http.session.close() + await self.close() def stop_loop_on_completion(f: asyncio.Future): loop.stop() diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index aa10bbe1..1a69ad61 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -11,13 +11,13 @@ from typing import TYPE_CHECKING, Any, DefaultDict, Dict, List, Optional, Union import aiohttp -from discord_typings import HelloData, ReadyData +from discord_typings import HelloEvent, InvalidSessionEvent, ReadyData from ..exceptions import ClosedWebSocketConnection from ..flags import Intents from ..presence import Presence -from ..types import GatewayCloseCode, IdentifyCommand, OpCode from ..utils import AsyncFunction +from ..types import GatewayCloseCode, IdentifyCommand, OpCode from .rate_limit_tools import GatewayRateLimiter from .ws_close_handler import ( CloseHandlerLog, @@ -69,6 +69,7 @@ def __init__(self, client: WebSocketClient): OpCode.INVALID_SESSION: self.invalid_session, OpCode.HELLO: self.hello, OpCode.HEARTBEAT_ACK: self.heartbeat_ack, + OpCode.DISPATCH: self._dispatch_event, } def event(self, func: AsyncFunction): @@ -80,7 +81,7 @@ def event(self, func: AsyncFunction): return func async def dispatch(self, event_name: str, *args, **kwargs): - """Dispatch an event to all event handlers.""" + """Dispatch an event to all relevant event handlers.""" for event in self.events[event_name]: asyncio.create_task(event(*args, **kwargs)) @@ -88,9 +89,22 @@ async def dispatch(self, event_name: str, *args, **kwargs): @staticmethod async def heartbeat_ack(_: Any): - """Handle the heartbeat ack event. OpCode 11.""" + """Handle the heartbeat ack event (OPCODE 11).""" ... + async def _dispatch_event(self, event: Dict[str, Any]): + """Handle the DISPATCH event (OPCODE 0).""" + event_name = event["t"].lower() + event_data = event["d"] + self.client.sequence = event["s"] + + if event_handler := getattr(self, f"_{event_name}", None): + asyncio.create_task(event_handler(event_data)) + + # TODO: Once we have completed the HTTP objects, + # we can then start to transform them before they reach + # the end user. + def wait_for( self, *, @@ -129,16 +143,19 @@ async def send_json(self, payload: Any): await self.client.ws.send_json(payload) async def _ready(self, data: ReadyData): + """Handle the READY dispatch event.""" self.client.session_id = data["session_id"] self.client.resume_url = data["resume_gateway_url"] await self.dispatch("ready", data) async def _resumed(self, _: Any): + """Handle the RESUMED dispatch event.""" logger.info("Resumed session %s", self.client.session_id) await self.dispatch("resumed") async def identify(self): + """Send the IDENTIFY payload to the Gateway (OPCODE 2).""" payload: IdentifyCommand = { "op": OpCode.IDENTIFY, "d": { @@ -159,8 +176,9 @@ async def identify(self): await self.send_json(payload) - async def hello(self, data: HelloData): - """Handle the hello event. OpCode 10.""" + async def hello(self, event: HelloEvent): + """Handle the hello event (OPCODE 10).""" + data = event["d"] self.client.heartbeat_interval = data["heartbeat_interval"] / 1000 await self.identify() @@ -186,7 +204,10 @@ async def heartbeat(self, *, forced: Optional[bool] = False): if not forced: await asyncio.sleep(self.client.heartbeat_interval) - await self.send_json({"op": OpCode.HEARTBEAT, "d": None}) + await self.send_json({ + "op": OpCode.HEARTBEAT, + "d": self.client.sequence + }) start = perf_counter_ns() @@ -199,6 +220,7 @@ async def heartbeat(self, *, forced: Optional[bool] = False): self.client._heartbeats.append(end - start) async def resume(self): + """Send the RESUME payload to the Gateway (OPCODE 6).""" await self.send_json( { "op": OpCode.RESUME, @@ -210,11 +232,14 @@ async def resume(self): ) async def reconnect(self, _): + """Handle the RECONNECT event (OPCODE 7).""" if self.client.ws: await self.client.ws.close() await self.client.connect(resume=True) - async def invalid_session(self, resumable: bool): + async def invalid_session(self, event: InvalidSessionEvent): + """Handle the INVALID_SESSION event (OPCODE 9).""" + resumable = event["d"] if self.client.ws: await self.client.ws.close() @@ -225,13 +250,8 @@ async def invalid_session(self, resumable: bool): else: await self.identify() - async def handle(self, message: DiscordWSMessage): - try: - event = message.json() - except json.JSONDecodeError: - logger.error("Failed to decode message: %s", message.data) - return - + async def _handle_wait_for(self, event: Dict[str, Any]): + """Set the Futures for wait_for events.""" values: List[Union[str, int]] = [] if event["op"] in self.wait_for_events: @@ -249,19 +269,15 @@ async def handle(self, message: DiscordWSMessage): wait_for_event.future.set_result(event) self.wait_for_events[value].remove(wait_for_event) - if event["op"] != OpCode.DISPATCH: - await self.opcode_mapping[event["op"]](event["d"]) - else: - event_name = event["t"].lower() - event_data = event["d"] - self.client.sequence = event["s"] - - if event_handler := getattr(self, f"_{event_name}", None): - asyncio.create_task(event_handler(event_data)) + async def handle(self, message: DiscordWSMessage): + try: + event = message.json() + except json.JSONDecodeError: + logger.error("Failed to decode message: %s", message.data) + return - # TODO: Once we have completed the HTTP objects, - # we can then start to transform them before they reach - # the end user. + await self._handle_wait_for(event) + await self.opcode_mapping[event["op"]](event) class DiscordWSMessage: diff --git a/setup.cfg b/setup.cfg index 187430c9..17e4e089 100644 --- a/setup.cfg +++ b/setup.cfg @@ -14,7 +14,7 @@ classifiers = Programming Language :: Python :: 3.8 license = MIT author = EpikCord -author_email = support@epikhost.xyz +author_email = EpikCord@theuntraceable.me url = https://github.com/EpikCord/EpikCord.py [flake8] From 5442d3fe123a406116edee247bf0865181ec2b0f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 7 Apr 2023 12:11:46 +0000 Subject: [PATCH 281/335] :art: Format code automatically --- EpikCord/client/websocket.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 1a69ad61..423db5ef 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -16,8 +16,8 @@ from ..exceptions import ClosedWebSocketConnection from ..flags import Intents from ..presence import Presence -from ..utils import AsyncFunction from ..types import GatewayCloseCode, IdentifyCommand, OpCode +from ..utils import AsyncFunction from .rate_limit_tools import GatewayRateLimiter from .ws_close_handler import ( CloseHandlerLog, @@ -204,10 +204,9 @@ async def heartbeat(self, *, forced: Optional[bool] = False): if not forced: await asyncio.sleep(self.client.heartbeat_interval) - await self.send_json({ - "op": OpCode.HEARTBEAT, - "d": self.client.sequence - }) + await self.send_json( + {"op": OpCode.HEARTBEAT, "d": self.client.sequence} + ) start = perf_counter_ns() From 78f238b375fe59bd069d16696ad6bcef19012bc1 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Thu, 13 Apr 2023 00:39:57 +0100 Subject: [PATCH 282/335] Log code that is passed --- EpikCord/client/websocket.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 423db5ef..e6f68575 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -313,7 +313,13 @@ async def receive(self, *args, **kwargs): data=message, msg_type=ws_message.type, extra=ws_message.extra ) - async def close(self, *, code: int = 4000, message: bytes = b"") -> bool: + async def close( + self, + *, + code: Optional[int] = None, + message: bytes = b"" + ) -> bool: + code = code or 4000 logger.debug( "Closing websocket with code %s. This is triggered by the library.", code, From 4554c68ad9af26c5229c3005e0cd6bffb8773e54 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 12 Apr 2023 23:41:14 +0000 Subject: [PATCH 283/335] :art: Format code automatically --- EpikCord/client/websocket.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index e6f68575..bfc24cd0 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -314,10 +314,7 @@ async def receive(self, *args, **kwargs): ) async def close( - self, - *, - code: Optional[int] = None, - message: bytes = b"" + self, *, code: Optional[int] = None, message: bytes = b"" ) -> bool: code = code or 4000 logger.debug( From b255fe3750ff3892bd9512d854536935ff0d5503 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Thu, 13 Apr 2023 00:41:57 +0100 Subject: [PATCH 284/335] Change code after logging --- EpikCord/client/websocket.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index e6f68575..af03376f 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -319,11 +319,12 @@ async def close( code: Optional[int] = None, message: bytes = b"" ) -> bool: - code = code or 4000 logger.debug( "Closing websocket with code %s. This is triggered by the library.", code, ) + code = code or 4000 + print(f"Code turned to {code}") return await super().close(code=code, message=message) From 78f1944270bf45a86901623b73b312c093be4354 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Thu, 13 Apr 2023 14:30:20 +0100 Subject: [PATCH 285/335] Store heartbeating task --- EpikCord/client/client.py | 3 +++ EpikCord/client/websocket.py | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index 8cc0ae99..2d1f8389 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -105,6 +105,9 @@ async def close(self): await self.http.session.close() if self.ws and not self.ws.closed: + if self._heartbeat_task: + self._heartbeat_task.cancel() + self._heartbeat_task = None await self.ws.close() self.rate_limiter.reset.cancel() diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index e893270a..2e81fdd8 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -186,7 +186,7 @@ async def heartbeat_task(): while True: await self.heartbeat() - asyncio.create_task(heartbeat_task()) + self.client._heartbeat_task = asyncio.create_task(heartbeat_task()) async def heartbeat(self, *, forced: Optional[bool] = False): """Send a heartbeat to the gateway. @@ -351,6 +351,7 @@ def __init__( self.resume_url: Optional[str] = None self.heartbeat_interval: Optional[float] = None self._heartbeats: List[int] = [] + self._heartbeat_task: Optional[asyncio.Task] = None self.event = self.event_handler.event From 30eac0b760d5b030155ca8a2feb2d0b5b4dedb4f Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Thu, 13 Apr 2023 18:09:02 +0100 Subject: [PATCH 286/335] Log when resetting counter --- EpikCord/client/rate_limit_tools.py | 1 + EpikCord/client/websocket.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/EpikCord/client/rate_limit_tools.py b/EpikCord/client/rate_limit_tools.py index e9f6fb5b..3c6ffbbc 100644 --- a/EpikCord/client/rate_limit_tools.py +++ b/EpikCord/client/rate_limit_tools.py @@ -24,6 +24,7 @@ def __init__(self): @tasks.task(duration=timedelta(seconds=_ONE_MINUTE)) async def reset(self): + logger.debug("Resetting gateway bucket.") self.remaining = self.limit self.event.set() diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 2e81fdd8..6a9df6ee 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -320,8 +320,8 @@ async def close( "Closing websocket with code %s. This is triggered by the library.", code, ) - code = code or 4000 print(f"Code turned to {code}") + code = code or 4000 return await super().close(code=code, message=message) @@ -380,7 +380,7 @@ async def connect(self, *, resume: bool = False): raise ValueError(f"Cannot connect to URL {url} (resume: {resume})") self.ws = await self.http.ws_connect(url) - asyncio.create_task(self.rate_limiter.reset.start()) + self.rate_limiter.reset.run() if resume: await self.event_handler.resume() From 57ae4b4bf69ee8dc534d86ec4cb5dd2a42ee2ce6 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Thu, 13 Apr 2023 18:10:48 +0100 Subject: [PATCH 287/335] Remove verbose debug --- EpikCord/client/websocket.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 6a9df6ee..14ade6e0 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -320,8 +320,6 @@ async def close( "Closing websocket with code %s. This is triggered by the library.", code, ) - print(f"Code turned to {code}") - code = code or 4000 return await super().close(code=code, message=message) From ace2d2ce7aba1041b2c69f8d2df31aa7bbe364da Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Thu, 13 Apr 2023 18:11:56 +0100 Subject: [PATCH 288/335] Make code default to 4000 --- EpikCord/client/websocket.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 14ade6e0..ec5dd3ac 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -314,7 +314,7 @@ async def receive(self, *args, **kwargs): ) async def close( - self, *, code: Optional[int] = None, message: bytes = b"" + self, *, code: int = 4000, message: bytes = b"" ) -> bool: logger.debug( "Closing websocket with code %s. This is triggered by the library.", From 280dcc4ae7680f70776ca00bb2cb9214c5128fad Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Thu, 13 Apr 2023 18:17:05 +0100 Subject: [PATCH 289/335] Log when resetting --- EpikCord/client/rate_limit_tools.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/EpikCord/client/rate_limit_tools.py b/EpikCord/client/rate_limit_tools.py index 3c6ffbbc..2bf6e508 100644 --- a/EpikCord/client/rate_limit_tools.py +++ b/EpikCord/client/rate_limit_tools.py @@ -24,14 +24,15 @@ def __init__(self): @tasks.task(duration=timedelta(seconds=_ONE_MINUTE)) async def reset(self): - logger.debug("Resetting gateway bucket.") self.remaining = self.limit self.event.set() + logger.debug("Reset gateway rate limit.") async def tick(self): await self.event.wait() self.remaining -= 1 if self.remaining == 0: + logger.critical("Gateway rate limit exhausted.") self.event.clear() From d57890a1c3f6c5534d7eb78c2bf740d597e0d2d0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 13 Apr 2023 17:17:52 +0000 Subject: [PATCH 290/335] :art: Format code automatically --- EpikCord/client/websocket.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index ec5dd3ac..c69f8468 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -313,9 +313,7 @@ async def receive(self, *args, **kwargs): data=message, msg_type=ws_message.type, extra=ws_message.extra ) - async def close( - self, *, code: int = 4000, message: bytes = b"" - ) -> bool: + async def close(self, *, code: int = 4000, message: bytes = b"") -> bool: logger.debug( "Closing websocket with code %s. This is triggered by the library.", code, From 77094b497a24acbb40b2079e7286ab6901598979 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Thu, 13 Apr 2023 19:26:05 +0100 Subject: [PATCH 291/335] Log task names --- EpikCord/ext/tasks.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/EpikCord/ext/tasks.py b/EpikCord/ext/tasks.py index e02f6552..855b4765 100644 --- a/EpikCord/ext/tasks.py +++ b/EpikCord/ext/tasks.py @@ -2,9 +2,12 @@ import typing from datetime import timedelta from typing import Callable, Coroutine, Final +from logging import getLogger INFINITE_RUNS: Final[int] = -1 +logger = getLogger("EpikCord.tasks") + class Task: def __init__( @@ -20,6 +23,7 @@ def __init__( self._task: typing.Optional[asyncio.Task] = None async def start(self, *args: typing.Any, **kwargs: typing.Any): + logger.info(f"Starting task {self.wrapped_func.__name__}") while self.runs_count < self.max_runs: await self.wrapped_func(*args, **kwargs) @@ -36,6 +40,7 @@ def run(self, *args: typing.Any, **kwargs: typing.Any): self._task = asyncio.create_task(self.start(*args, **kwargs)) def cancel(self): + logger.info(f"Cancelling task {self.wrapped_func.__name__}") if self._task is not None: self._task.cancel() From e394d88fa4d8c27fd6f2454e6baf67b1fec467ff Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 13 Apr 2023 18:26:58 +0000 Subject: [PATCH 292/335] :art: Format code automatically --- EpikCord/ext/tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EpikCord/ext/tasks.py b/EpikCord/ext/tasks.py index 855b4765..1332c80e 100644 --- a/EpikCord/ext/tasks.py +++ b/EpikCord/ext/tasks.py @@ -1,8 +1,8 @@ import asyncio import typing from datetime import timedelta -from typing import Callable, Coroutine, Final from logging import getLogger +from typing import Callable, Coroutine, Final INFINITE_RUNS: Final[int] = -1 From a3637271c7ce8e08a35c215c72f4f59a565a1dd8 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Thu, 13 Apr 2023 23:28:50 +0100 Subject: [PATCH 293/335] Log in start task --- EpikCord/client/rate_limit_tools.py | 2 +- EpikCord/ext/tasks.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/EpikCord/client/rate_limit_tools.py b/EpikCord/client/rate_limit_tools.py index 2bf6e508..92875eba 100644 --- a/EpikCord/client/rate_limit_tools.py +++ b/EpikCord/client/rate_limit_tools.py @@ -22,7 +22,7 @@ def __init__(self): self.remaining = MAXIMUM_GATEWAY_EVENT self.limit = MAXIMUM_GATEWAY_EVENT - @tasks.task(duration=timedelta(seconds=_ONE_MINUTE)) + @tasks.task(duration=timedelta(minutes=1)) async def reset(self): self.remaining = self.limit self.event.set() diff --git a/EpikCord/ext/tasks.py b/EpikCord/ext/tasks.py index 855b4765..0f3c9489 100644 --- a/EpikCord/ext/tasks.py +++ b/EpikCord/ext/tasks.py @@ -30,6 +30,9 @@ async def start(self, *args: typing.Any, **kwargs: typing.Any): if self.has_limited_runs: self.runs_count += 1 + logger.debug( + f"Ran task {self.wrapped_func.__name__} for " + f"{self.runs_count} time(s).") await asyncio.sleep(self.duration) @property @@ -48,5 +51,4 @@ def cancel(self): def task(duration: timedelta, max_runs=INFINITE_RUNS): def wrap(function): return Task(function, duration.total_seconds(), max_runs) - return wrap From 34a979406a181845912378a6404f6d14888a2ee9 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Thu, 13 Apr 2023 23:30:32 +0100 Subject: [PATCH 294/335] Set name to Task --- EpikCord/ext/tasks.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/EpikCord/ext/tasks.py b/EpikCord/ext/tasks.py index 0f3c9489..6cab73e3 100644 --- a/EpikCord/ext/tasks.py +++ b/EpikCord/ext/tasks.py @@ -41,6 +41,10 @@ def has_limited_runs(self) -> bool: def run(self, *args: typing.Any, **kwargs: typing.Any): self._task = asyncio.create_task(self.start(*args, **kwargs)) + self._task.set_name(self.wrapped_func.__name__) + self._task.add_done_callback( + lambda task: logger.info(f"Task {task.get_name()} finished.") + ) def cancel(self): logger.info(f"Cancelling task {self.wrapped_func.__name__}") From 0452bc5ed8efda5e00ded545409d7881b866bb01 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 13 Apr 2023 22:31:34 +0000 Subject: [PATCH 295/335] :art: Format code automatically --- EpikCord/ext/tasks.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/EpikCord/ext/tasks.py b/EpikCord/ext/tasks.py index f4a3b624..5befa7d6 100644 --- a/EpikCord/ext/tasks.py +++ b/EpikCord/ext/tasks.py @@ -32,7 +32,8 @@ async def start(self, *args: typing.Any, **kwargs: typing.Any): logger.debug( f"Ran task {self.wrapped_func.__name__} for " - f"{self.runs_count} time(s).") + f"{self.runs_count} time(s)." + ) await asyncio.sleep(self.duration) @property @@ -55,4 +56,5 @@ def cancel(self): def task(duration: timedelta, max_runs=INFINITE_RUNS): def wrap(function): return Task(function, duration.total_seconds(), max_runs) + return wrap From b639a6200a929cba657babe699a887b1e57cbabf Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Thu, 13 Apr 2023 23:50:12 +0100 Subject: [PATCH 296/335] Current state --- EpikCord/client/rate_limit_tools.py | 5 +++-- EpikCord/ext/tasks.py | 19 ++++++++++++------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/EpikCord/client/rate_limit_tools.py b/EpikCord/client/rate_limit_tools.py index 92875eba..42cbc884 100644 --- a/EpikCord/client/rate_limit_tools.py +++ b/EpikCord/client/rate_limit_tools.py @@ -10,7 +10,8 @@ logger = getLogger("EpikCord.http") -_ONE_MINUTE: Final[int] = 60 +_ONE_MINUTE: Final[timedelta] = timedelta(minutes=1) +_MAX_RUNS: Final[int] = -1 MAXIMUM_GATEWAY_EVENT: Final[int] = 120 @@ -22,7 +23,7 @@ def __init__(self): self.remaining = MAXIMUM_GATEWAY_EVENT self.limit = MAXIMUM_GATEWAY_EVENT - @tasks.task(duration=timedelta(minutes=1)) + @tasks.task(duration=_ONE_MINUTE, max_runs=_MAX_RUNS) async def reset(self): self.remaining = self.limit self.event.set() diff --git a/EpikCord/ext/tasks.py b/EpikCord/ext/tasks.py index f4a3b624..6fe2b989 100644 --- a/EpikCord/ext/tasks.py +++ b/EpikCord/ext/tasks.py @@ -24,16 +24,21 @@ def __init__( async def start(self, *args: typing.Any, **kwargs: typing.Any): logger.info(f"Starting task {self.wrapped_func.__name__}") - while self.runs_count < self.max_runs: - await self.wrapped_func(*args, **kwargs) - if self.has_limited_runs: + if self.has_limited_runs: + while ( + self.runs_count < self.max_runs + if self.has_limited_runs + else True + ): + await self.wrapped_func(*args, **kwargs) + self.runs_count += 1 - logger.debug( - f"Ran task {self.wrapped_func.__name__} for " - f"{self.runs_count} time(s).") - await asyncio.sleep(self.duration) + logger.debug( + f"Ran task {self.wrapped_func.__name__} for " + f"{self.runs_count} time(s).") + await asyncio.sleep(self.duration) @property def has_limited_runs(self) -> bool: From b73fa3f24a7840dc401f934f8d59b22bc999554d Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Fri, 14 Apr 2023 00:22:39 +0100 Subject: [PATCH 297/335] Fix task code --- EpikCord/ext/tasks.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/EpikCord/ext/tasks.py b/EpikCord/ext/tasks.py index 398531fa..4f6a3d21 100644 --- a/EpikCord/ext/tasks.py +++ b/EpikCord/ext/tasks.py @@ -25,21 +25,21 @@ def __init__( async def start(self, *args: typing.Any, **kwargs: typing.Any): logger.info(f"Starting task {self.wrapped_func.__name__}") - if self.has_limited_runs: - while ( - self.runs_count < self.max_runs - if self.has_limited_runs - else True - ): - await self.wrapped_func(*args, **kwargs) + while ( + self.runs_count < self.max_runs + if self.has_limited_runs + else True + ): + await self.wrapped_func(*args, **kwargs) - self.runs_count += 1 + self.runs_count += 1 + + await asyncio.sleep(self.duration) logger.debug( f"Ran task {self.wrapped_func.__name__} for " f"{self.runs_count} time(s)." ) - await asyncio.sleep(self.duration) @property def has_limited_runs(self) -> bool: From 641d4a481f1562e27de24c4f2b77184babde651a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 13 Apr 2023 23:23:36 +0000 Subject: [PATCH 298/335] :art: Format code automatically --- EpikCord/ext/tasks.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/EpikCord/ext/tasks.py b/EpikCord/ext/tasks.py index 4f6a3d21..13cb48e8 100644 --- a/EpikCord/ext/tasks.py +++ b/EpikCord/ext/tasks.py @@ -26,9 +26,7 @@ async def start(self, *args: typing.Any, **kwargs: typing.Any): logger.info(f"Starting task {self.wrapped_func.__name__}") while ( - self.runs_count < self.max_runs - if self.has_limited_runs - else True + self.runs_count < self.max_runs if self.has_limited_runs else True ): await self.wrapped_func(*args, **kwargs) From 23c6fe48d798e589fbe9965bb5979620e12c122e Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Fri, 14 Apr 2023 12:20:00 +0100 Subject: [PATCH 299/335] Move away from tasks --- EpikCord/client/rate_limit_tools.py | 5 ----- EpikCord/client/websocket.py | 8 +++++++- EpikCord/ext/tasks.py | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/EpikCord/client/rate_limit_tools.py b/EpikCord/client/rate_limit_tools.py index 42cbc884..c428b12b 100644 --- a/EpikCord/client/rate_limit_tools.py +++ b/EpikCord/client/rate_limit_tools.py @@ -1,17 +1,13 @@ from __future__ import annotations import asyncio -from datetime import timedelta from logging import getLogger from typing import Any, Dict, Final, Optional, Union -from ..ext import tasks from ..utils import clear_none_values logger = getLogger("EpikCord.http") -_ONE_MINUTE: Final[timedelta] = timedelta(minutes=1) -_MAX_RUNS: Final[int] = -1 MAXIMUM_GATEWAY_EVENT: Final[int] = 120 @@ -23,7 +19,6 @@ def __init__(self): self.remaining = MAXIMUM_GATEWAY_EVENT self.limit = MAXIMUM_GATEWAY_EVENT - @tasks.task(duration=_ONE_MINUTE, max_runs=_MAX_RUNS) async def reset(self): self.remaining = self.limit self.event.set() diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index c69f8468..54ad626e 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -376,7 +376,13 @@ async def connect(self, *, resume: bool = False): raise ValueError(f"Cannot connect to URL {url} (resume: {resume})") self.ws = await self.http.ws_connect(url) - self.rate_limiter.reset.run() + + async def forever_reset(): + while True: + await asyncio.sleep(120) + await self.rate_limiter.reset() + + asyncio.create_task(forever_reset()) if resume: await self.event_handler.resume() diff --git a/EpikCord/ext/tasks.py b/EpikCord/ext/tasks.py index 4f6a3d21..6197c241 100644 --- a/EpikCord/ext/tasks.py +++ b/EpikCord/ext/tasks.py @@ -19,7 +19,7 @@ def __init__( self.wrapped_func: Callable[..., Coroutine] = wrapped_func self.duration: float = duration self.max_runs: int = max_runs - self.runs_count: int = INFINITE_RUNS + self.runs_count: int = 0 self._task: typing.Optional[asyncio.Task] = None async def start(self, *args: typing.Any, **kwargs: typing.Any): From 60d0e9e75bc17790eb7dc3e5e3eb16ece221fbff Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Fri, 14 Apr 2023 12:21:11 +0100 Subject: [PATCH 300/335] reset every 60s --- EpikCord/client/rate_limit_tools.py | 1 + EpikCord/client/websocket.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/EpikCord/client/rate_limit_tools.py b/EpikCord/client/rate_limit_tools.py index c428b12b..6b2a567f 100644 --- a/EpikCord/client/rate_limit_tools.py +++ b/EpikCord/client/rate_limit_tools.py @@ -25,6 +25,7 @@ async def reset(self): logger.debug("Reset gateway rate limit.") async def tick(self): + logger.debug("Ticking gateway rate limit.") await self.event.wait() self.remaining -= 1 if self.remaining == 0: diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 54ad626e..f16b8e81 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -379,7 +379,7 @@ async def connect(self, *, resume: bool = False): async def forever_reset(): while True: - await asyncio.sleep(120) + await asyncio.sleep(60) await self.rate_limiter.reset() asyncio.create_task(forever_reset()) From 9d9e9d2977a7c2fe13bc144d1eca1e512384561e Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Fri, 14 Apr 2023 12:24:09 +0100 Subject: [PATCH 301/335] Cancel task when closing --- EpikCord/client/websocket.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index f16b8e81..3c9feef0 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -339,6 +339,7 @@ def __init__( self.rate_limiter: GatewayRateLimiter = GatewayRateLimiter() self.event_handler: GatewayEventHandler = GatewayEventHandler(self) + self._rate_limiter_task: Optional[asyncio.Task] = None self._gateway_url: Optional[str] = None @@ -382,7 +383,7 @@ async def forever_reset(): await asyncio.sleep(60) await self.rate_limiter.reset() - asyncio.create_task(forever_reset()) + self._rate_limiter_task = asyncio.create_task(forever_reset()) if resume: await self.event_handler.resume() @@ -399,6 +400,8 @@ async def handle_close(self): return close_code = self.ws.close_code + if self._rate_limiter_task: + self._rate_limiter_task.cancel() logger.critical("Websocket closed with code %s", close_code) From b417582f0f5c285443bcb80707f8a066269a7023 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sat, 15 Apr 2023 00:57:14 +0100 Subject: [PATCH 302/335] Send RESUME payload after HELLO --- EpikCord/client/websocket.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 3c9feef0..2f4f6dd2 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -385,8 +385,8 @@ async def forever_reset(): self._rate_limiter_task = asyncio.create_task(forever_reset()) - if resume: - await self.event_handler.resume() + await self.event_handler.wait_for(opcode=OpCode.HELLO) + await self.event_handler.resume() async for message in self.ws: logger.debug("Received message: %s", message.json()) From 583b4270581ca3ed433fb795edc324eddc1910b7 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sat, 15 Apr 2023 00:58:28 +0100 Subject: [PATCH 303/335] Attach QS Params to WS URL --- EpikCord/client/websocket.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 2f4f6dd2..8be1adb8 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -365,10 +365,8 @@ async def connect(self, *, resume: bool = False): version = self.http.version.value url = ( - f"{self._gateway_url}?v={version}" - "&encoding=json&compress=zlib-stream" - if not resume - else self.resume_url + f"{self._gateway_url if not resume else self.resume_url}" + f"?v={version}&encoding=json&compress=zlib-stream" ) if not self.resume_url and resume: From 1b6aabbcd9d244e9e95672085e1bb9fd3a6ade05 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sat, 15 Apr 2023 00:59:38 +0100 Subject: [PATCH 304/335] Wait for HELLO only when RESUMing --- EpikCord/client/websocket.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 8be1adb8..960b2584 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -383,8 +383,9 @@ async def forever_reset(): self._rate_limiter_task = asyncio.create_task(forever_reset()) - await self.event_handler.wait_for(opcode=OpCode.HELLO) - await self.event_handler.resume() + if resume: + await self.event_handler.wait_for(opcode=OpCode.HELLO) + await self.event_handler.resume() async for message in self.ws: logger.debug("Received message: %s", message.json()) From 81d5fdf378b16c3cf4529d136fa7b8a868777af2 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sat, 15 Apr 2023 14:22:28 +0100 Subject: [PATCH 305/335] Stop heartbeat task when closing --- EpikCord/client/client.py | 8 ++++---- EpikCord/client/websocket.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index 2d1f8389..87e18fdb 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -99,10 +99,7 @@ def stop_loop_on_completion(f: asyncio.Future): cleanup_loop(loop) async def close(self): - logger.critical("Client.close called") - - if not self.http.session.closed: - await self.http.session.close() + logger.critical("Closing connections") if self.ws and not self.ws.closed: if self._heartbeat_task: @@ -110,6 +107,9 @@ async def close(self): self._heartbeat_task = None await self.ws.close() + if not self.http.session.closed: + await self.http.session.close() + self.rate_limiter.reset.cancel() async def change_presence(self, presence: Presence): diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 960b2584..96c633ac 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -233,6 +233,8 @@ async def resume(self): async def reconnect(self, _): """Handle the RECONNECT event (OPCODE 7).""" if self.client.ws: + if self.client._heartbeat_task: + self.client._heartbeat_task.cancel() await self.client.ws.close() await self.client.connect(resume=True) @@ -240,6 +242,8 @@ async def invalid_session(self, event: InvalidSessionEvent): """Handle the INVALID_SESSION event (OPCODE 9).""" resumable = event["d"] if self.client.ws: + if self.client._heartbeat_task: + self.client._heartbeat_task.cancel() await self.client.ws.close() await self.client.connect(resume=resumable) @@ -314,10 +318,6 @@ async def receive(self, *args, **kwargs): ) async def close(self, *, code: int = 4000, message: bytes = b"") -> bool: - logger.debug( - "Closing websocket with code %s. This is triggered by the library.", - code, - ) return await super().close(code=code, message=message) From 2b439bafa088cacb1695b1a4a19b6c8e10b3da57 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sat, 29 Apr 2023 15:57:40 +0100 Subject: [PATCH 306/335] Add ruff to pyproject.toml --- pyproject.toml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 572fb970..947f10f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,3 +12,22 @@ exclude = [ "venv", "**/__pycache__" ] + +[tool.ruff] +exclude = [ + ".git", + ".nox", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "venv", + "__pycache__", +] +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" +line-length = 88 \ No newline at end of file From 0de32ecabfb70613efeb792eb633fd660fca6edc Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sat, 29 Apr 2023 16:02:07 +0100 Subject: [PATCH 307/335] Remove mentions of Flake8 --- .flake8 | 5 ----- flake.lock | 43 ------------------------------------------- flake.nix | 21 --------------------- pyproject.toml | 3 +-- setup.cfg | 3 --- 5 files changed, 1 insertion(+), 74 deletions(-) delete mode 100644 .flake8 delete mode 100644 flake.lock delete mode 100644 flake.nix diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 215dcf81..00000000 --- a/.flake8 +++ /dev/null @@ -1,5 +0,0 @@ -[flake8] -ignore = E203 - # whitespace before : in slicing. -exclude = .git, .idea, __pycache__, build, venv -max-complexity = 10 diff --git a/flake.lock b/flake.lock deleted file mode 100644 index 9772e4a1..00000000 --- a/flake.lock +++ /dev/null @@ -1,43 +0,0 @@ -{ - "nodes": { - "nixpkgs": { - "locked": { - "lastModified": 1678872516, - "narHash": "sha256-/E1YwtMtFAu2KUQKV/1+KFuReYPANM2Rzehk84VxVoc=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "9b8e5abb18324c7fe9f07cb100c3cd4a29cda8b8", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-22.11", - "repo": "nixpkgs", - "type": "github" - } - }, - "root": { - "inputs": { - "nixpkgs": "nixpkgs", - "utils": "utils" - } - }, - "utils": { - "locked": { - "lastModified": 1678901627, - "narHash": "sha256-U02riOqrKKzwjsxc/400XnElV+UtPUQWpANPlyazjH0=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "93a2b84fc4b70d9e089d029deacc3583435c2ed6", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - } - }, - "root": "root", - "version": 7 -} diff --git a/flake.nix b/flake.nix deleted file mode 100644 index 5ba9865b..00000000 --- a/flake.nix +++ /dev/null @@ -1,21 +0,0 @@ -{ - description = "EpikCord Python Library"; - - inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-22.11"; - utils.url = "github:numtide/flake-utils"; - }; - - outputs = { self, nixpkgs, utils } @inputs: - utils.lib.eachDefaultSystem (system: - with import nixpkgs { inherit system; }; { - devShells.default = mkShell { - venvDir = "venv"; - buildInputs = [ - pkgs.gnumake - pkgs.python310Full - pkgs.python310Packages.venvShellHook - ]; - }; - }); -} diff --git a/pyproject.toml b/pyproject.toml index 947f10f5..acf5e4e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,8 +3,7 @@ requires = ["setuptools"] build-backend = "setuptools.build_meta" [tool.black] -line-length = 80 -target-version = ['py38'] +line-length = 88 [tool.pyright] include = ["EpikCord"] diff --git a/setup.cfg b/setup.cfg index 17e4e089..ec2501d7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -17,9 +17,6 @@ author = EpikCord author_email = EpikCord@theuntraceable.me url = https://github.com/EpikCord/EpikCord.py -[flake8] -max-line-length = 80 - [options] zip_safe = False include_package_data = True From 1496139df1d6ebca7b2721a36e4b312de8f8047f Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sat, 29 Apr 2023 16:02:55 +0100 Subject: [PATCH 308/335] In nox use ruff --- noxfile.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/noxfile.py b/noxfile.py index 09c31ac2..467453ce 100644 --- a/noxfile.py +++ b/noxfile.py @@ -16,8 +16,8 @@ def format_(session: nox.Session): @nox.session def lint(session: nox.Session): - session.install("flake8") - session.run("flake8", "EpikCord") + session.install("ruff") + session.run("ruff", "EpikCord") @nox.session From 0e8bddff778fd4e1b263f2b88b5c08f3fcdf049c Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sat, 29 Apr 2023 16:04:50 +0100 Subject: [PATCH 309/335] Move isort config to setup.cfg --- .isort.cfg | 3 --- setup.cfg | 4 ++++ 2 files changed, 4 insertions(+), 3 deletions(-) delete mode 100644 .isort.cfg diff --git a/.isort.cfg b/.isort.cfg deleted file mode 100644 index d056ad08..00000000 --- a/.isort.cfg +++ /dev/null @@ -1,3 +0,0 @@ -[settings] -profile = black -line_length = 80 diff --git a/setup.cfg b/setup.cfg index ec2501d7..78edfd08 100644 --- a/setup.cfg +++ b/setup.cfg @@ -34,3 +34,7 @@ voice = pynacl testing = nox + +[isort] +profile = black +line_length = 88 \ No newline at end of file From f0a1c30846cf1bce33eac87921b1dbbcb408f8b0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 29 Apr 2023 15:06:14 +0000 Subject: [PATCH 310/335] :art: Format code automatically --- EpikCord/client/client.py | 8 ++------ EpikCord/client/http.py | 20 ++++---------------- EpikCord/client/rate_limit_tools.py | 12 +++--------- EpikCord/client/websocket.py | 22 +++++----------------- EpikCord/client/ws_close_handler.py | 8 ++------ EpikCord/exceptions.py | 7 ++----- EpikCord/ext/tasks.py | 4 +--- EpikCord/flags.py | 8 ++------ EpikCord/guild.py | 20 +++++--------------- EpikCord/interaction.py | 22 ++++++---------------- EpikCord/presence.py | 8 ++------ EpikCord/types/interactions.py | 4 +--- EpikCord/user.py | 8 ++------ EpikCord/utils/__init__.py | 7 +------ EpikCord/utils/loose.py | 10 +++------- 15 files changed, 41 insertions(+), 127 deletions(-) diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index 87e18fdb..fe173e7e 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -69,9 +69,7 @@ def __init__( http=HTTPClient(token, version=version), ) self.commands: List[ - Union[ - ClientChatInputCommand, ClientUserCommand, ClientMessageCommand - ] + Union[ClientChatInputCommand, ClientUserCommand, ClientMessageCommand] ] = [] def login(self): @@ -115,9 +113,7 @@ async def close(self): async def change_presence(self, presence: Presence): if not self.ws: raise RuntimeError("Client is not connected to the gateway.") - await self.ws.send_json( - {"op": OpCode.PRESENCE_UPDATE, "d": presence.to_dict()} - ) + await self.ws.send_json({"op": OpCode.PRESENCE_UPDATE, "d": presence.to_dict()}) def command( self, diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 4c7ba6f9..8066d007 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -26,13 +26,7 @@ json_serialize, log_request, ) -from .rate_limit_tools import ( - Bucket, - MajorParameters, - MockBucket, - Route, - TopLevelBucket, -) +from .rate_limit_tools import Bucket, MajorParameters, MockBucket, Route, TopLevelBucket from .websocket import GatewayWebSocket if TYPE_CHECKING: @@ -132,9 +126,7 @@ class HTTPClient: HTTPCodes.NOT_FOUND: NotFound, } - def __init__( - self, token: TokenStore, *, version: APIVersion = APIVersion.TEN - ): + def __init__(self, token: TokenStore, *, version: APIVersion = APIVersion.TEN): """ Parameters ---------- @@ -241,9 +233,7 @@ async def request( raise error(data) elif response.ok: return response - raise TooManyRetries( - f"Attempted {method} request to {url} 5 times but failed." - ) + raise TooManyRetries(f"Attempted {method} request to {url} 5 times but failed.") async def set_bucket( self, @@ -281,9 +271,7 @@ async def set_bucket( bucket = Bucket(bucket_hash=response.headers["X-RateLimit-Bucket"]) if bucket in self.buckets.values(): listed_buckets = list(self.buckets.values()) - self.buckets[bucket_key] = listed_buckets[ - listed_buckets.index(bucket) - ] + self.buckets[bucket_key] = listed_buckets[listed_buckets.index(bucket)] bucket = self.buckets[bucket_key] self.buckets[bucket_key] = bucket diff --git a/EpikCord/client/rate_limit_tools.py b/EpikCord/client/rate_limit_tools.py index 6b2a567f..301394ae 100644 --- a/EpikCord/client/rate_limit_tools.py +++ b/EpikCord/client/rate_limit_tools.py @@ -114,9 +114,7 @@ async def handle_exhaustion(self, retry_after: int): The amount of time to wait before retrying. """ self.clear() - logger.info( - f"Bucket {self.hash} exhausted, waiting {retry_after} seconds." - ) + logger.info(f"Bucket {self.hash} exhausted, waiting {retry_after} seconds.") await asyncio.sleep(retry_after) self.set() @@ -165,9 +163,7 @@ def __init__( self.guild_id: Optional[int] = guild_id self.webhook_id: Optional[int] = webhook_id self.webhook_token: Optional[str] = webhook_token - self.major_parameters: Dict[ - str, Optional[Union[int, str]] - ] = clear_none_values( + self.major_parameters: Dict[str, Optional[Union[int, str]]] = clear_none_values( { "channel_id": channel_id, "guild_id": guild_id, @@ -219,9 +215,7 @@ async def handle_exhaustion(self, retry_after: int): The amount of time to wait before retrying. """ self.clear() - logger.info( - f"Bucket {str(self)} exhausted, waiting {retry_after} seconds." - ) + logger.info(f"Bucket {str(self)} exhausted, waiting {retry_after} seconds.") await asyncio.sleep(retry_after) self.set() diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 96c633ac..2be5a58c 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -19,11 +19,7 @@ from ..types import GatewayCloseCode, IdentifyCommand, OpCode from ..utils import AsyncFunction from .rate_limit_tools import GatewayRateLimiter -from .ws_close_handler import ( - CloseHandlerLog, - CloseHandlerRaise, - close_dispatcher, -) +from .ws_close_handler import CloseHandlerLog, CloseHandlerRaise, close_dispatcher _ORJSON = find_spec("orjson") @@ -59,9 +55,7 @@ def __init__( class GatewayEventHandler: def __init__(self, client: WebSocketClient): self.client = client - self.wait_for_events: DefaultDict[Union[str, int], List] = defaultdict( - list - ) + self.wait_for_events: DefaultDict[Union[str, int], List] = defaultdict(list) self.events: DefaultDict[str, List[AsyncFunction]] = defaultdict(list) self.opcode_mapping: Dict[OpCode, AsyncFunction] = { OpCode.HEARTBEAT: partial(self.heartbeat, forced=True), @@ -118,9 +112,7 @@ def wait_for( elif name and opcode: raise ValueError("Only name or opcode can be provided.") - event = WaitForEvent( - name=name, opcode=opcode, timeout=timeout, check=check - ) + event = WaitForEvent(name=name, opcode=opcode, timeout=timeout, check=check) if opcode: self.wait_for_events[opcode.value].append(event) @@ -131,9 +123,7 @@ def wait_for( async def send_json(self, payload: Any): if not self.client.ws: - logger.error( - "Tried to send a payload without a websocket connection." - ) + logger.error("Tried to send a payload without a websocket connection.") return await self.client.rate_limiter.tick() logger.debug("Sending %s to Gateway", payload) @@ -204,9 +194,7 @@ async def heartbeat(self, *, forced: Optional[bool] = False): if not forced: await asyncio.sleep(self.client.heartbeat_interval) - await self.send_json( - {"op": OpCode.HEARTBEAT, "d": self.client.sequence} - ) + await self.send_json({"op": OpCode.HEARTBEAT, "d": self.client.sequence}) start = perf_counter_ns() diff --git a/EpikCord/client/ws_close_handler.py b/EpikCord/client/ws_close_handler.py index b1e01261..02da8f1a 100644 --- a/EpikCord/client/ws_close_handler.py +++ b/EpikCord/client/ws_close_handler.py @@ -20,18 +20,14 @@ def __init__(self, resumable: bool = True): class CloseHandlerRaise(CloseHandler): - def __init__( - self, exception: Type[Exception], message: Optional[str] = None - ): + def __init__(self, exception: Type[Exception], message: Optional[str] = None): super().__init__(resumable=False) self.exception = exception self.message = message class CloseHandlerLog(CloseHandler): - def __init__( - self, message: str, resumable: bool = True, need_report: bool = False - ): + def __init__(self, message: str, resumable: bool = True, need_report: bool = False): super().__init__(resumable=resumable) self.message = message self.need_report = need_report diff --git a/EpikCord/exceptions.py b/EpikCord/exceptions.py index 3d7642b7..b4a49d76 100644 --- a/EpikCord/exceptions.py +++ b/EpikCord/exceptions.py @@ -22,9 +22,7 @@ def __init__(self, data: Dict): self.errors_list = self.extract_errors(self.errors) super().__init__( - "\n".join( - f"{e.path} - {e.code} - {e.message}" for e in self.errors_list - ) + "\n".join(f"{e.path} - {e.code} - {e.message}" for e in self.errors_list) ) def extract_errors(self, d, key_path=None): @@ -37,8 +35,7 @@ def extract_errors(self, d, key_path=None): LocatedError( **error, path=".".join( - f"{k}[{k}]" if isinstance(k, int) else k - for k in key_path[1:] + f"{k}[{k}]" if isinstance(k, int) else k for k in key_path[1:] ), ) for error in d.get("_errors", []) diff --git a/EpikCord/ext/tasks.py b/EpikCord/ext/tasks.py index 25ccbb63..0dbf0aee 100644 --- a/EpikCord/ext/tasks.py +++ b/EpikCord/ext/tasks.py @@ -25,9 +25,7 @@ def __init__( async def start(self, *args: typing.Any, **kwargs: typing.Any): logger.info(f"Starting task {self.wrapped_func.__name__}") - while ( - self.runs_count < self.max_runs if self.has_limited_runs else True - ): + while self.runs_count < self.max_runs if self.has_limited_runs else True: await self.wrapped_func(*args, **kwargs) self.runs_count += 1 diff --git a/EpikCord/flags.py b/EpikCord/flags.py index 47d4f4c5..7d21b7a0 100644 --- a/EpikCord/flags.py +++ b/EpikCord/flags.py @@ -9,9 +9,7 @@ class Flag: class_flags: Dict[str, int] def __init_subclass__(cls) -> None: - cls.class_flags = { - k: v for k, v in cls.__dict__.items() if isinstance(v, int) - } + cls.class_flags = {k: v for k, v in cls.__dict__.items() if isinstance(v, int)} def __init__(self, value: int = ALL_VALUE_DISABLED, **kwargs): self.turned_on: List[str] = [ @@ -27,9 +25,7 @@ def __init__(self, value: int = ALL_VALUE_DISABLED, **kwargs): @property def value(self) -> int: return sum( - flag - for key, flag in self.class_flags.items() - if key in self.turned_on + flag for key, flag in self.class_flags.items() if key in self.turned_on ) def __getattribute__(self, __name: str) -> Any: diff --git a/EpikCord/guild.py b/EpikCord/guild.py index 49090497..99486bc1 100644 --- a/EpikCord/guild.py +++ b/EpikCord/guild.py @@ -19,9 +19,7 @@ def __init__(self, client: Client, data: GuildMemberData): # self.roles = [Role(client, role) for role in data.get("roles", [])] self.roles = data["roles"] self.joined_at = ( - datetime.fromisoformat(data["joined_at"]) - if data.get("joined_at") - else None + datetime.fromisoformat(data["joined_at"]) if data.get("joined_at") else None ) self.premium_since = instance_or_none( datetime.fromisoformat, data.get("premium_since") @@ -32,9 +30,7 @@ def __init__(self, client: Client, data: GuildMemberData): raw_flags = data["flags"] # type: ignore # DiscordTyping/32 self.flags = GuildMemberFlags(raw_flags) self.pending = data.get("pending") - self.permissions = instance_or_none( - Permissions, data.get("permissions") - ) + self.permissions = instance_or_none(Permissions, data.get("permissions")) self.communication_disabled_until = instance_or_none( datetime.fromisoformat, data.get("communication_disabled_until") ) @@ -45,18 +41,12 @@ def __init__(self, data: RoleTagsData): self._data = data self.bot_id = int_or_none(data.get("bot_id")) self.integration_id = int_or_none(data.get("integration_id")) - self.premium_subscriber = ( - True if data.get("premium_subscriber") else False - ) - self.subscription_listing_id = int_or_none( - data.get("subscription_listing_id") - ) + self.premium_subscriber = True if data.get("premium_subscriber") else False + self.subscription_listing_id = int_or_none(data.get("subscription_listing_id")) self.available_for_purchase = ( True if data.get("available_for_purchase") else False ) - self.guild_connections = ( - True if data.get("guild_connections") else False - ) + self.guild_connections = True if data.get("guild_connections") else False class Role: diff --git a/EpikCord/interaction.py b/EpikCord/interaction.py index e918a248..07902a77 100644 --- a/EpikCord/interaction.py +++ b/EpikCord/interaction.py @@ -57,21 +57,15 @@ def __init__(self, client: Client, data: InteractionData): class ResolvedInteractionData: def __init__(self, client: Client, data: ResolvedInteractionDataData): - self.users = { - int(k): User(client, v) for k, v in data.get("users", {}).items() - } + self.users = {int(k): User(client, v) for k, v in data.get("users", {}).items()} self.members = { - int(k): GuildMember(client, v) - for k, v in data.get("members", {}).items() - } - self.roles = { - int(k): Role(client, v) for k, v in data.get("roles", {}).items() + int(k): GuildMember(client, v) for k, v in data.get("members", {}).items() } + self.roles = {int(k): Role(client, v) for k, v in data.get("roles", {}).items()} # CHANNELS # MESSAGES self.attachments = { - int(k): Attachment(v) - for k, v in data.get("attachments", {}).items() + int(k): Attachment(v) for k, v in data.get("attachments", {}).items() } @@ -91,9 +85,7 @@ def __init__(self, client: Client, data: ApplicationCommandInteractionData): self.command_type: ApplicationCommandType = ApplicationCommandType( self.data["type"] ) - self.resolved = instance_or_none( - ResolvedInteractionData, data.get("resolved") - ) + self.resolved = instance_or_none(ResolvedInteractionData, data.get("resolved")) self._resolved = data.get("resolved") @@ -123,9 +115,7 @@ class BaseContextMenuInteraction(BaseApplicationCommandInteraction): def __init__( self, client: Client, - data: Union[ - UserContextMenuInteractionData, MessageContextMenuInteractionData - ], + data: Union[UserContextMenuInteractionData, MessageContextMenuInteractionData], ): super().__init__(client, data) self.data: Union[ diff --git a/EpikCord/presence.py b/EpikCord/presence.py index df8d148c..16dcaa5c 100644 --- a/EpikCord/presence.py +++ b/EpikCord/presence.py @@ -36,9 +36,7 @@ class UpdatePresenceData(TypedDict): class Activity: - def __init__( - self, *, name: str, ac_type: ActivityType, url: Optional[str] = None - ): + def __init__(self, *, name: str, ac_type: ActivityType, url: Optional[str] = None): """Represents a Discord activity. Parameters @@ -85,9 +83,7 @@ def __init__( The activity of the presence. """ if not status and not activity: - raise ValueError( - "Presence must have either a status or an activity." - ) + raise ValueError("Presence must have either a status or an activity.") self.status: Optional[Status] = status self.activity: Optional[Activity] = activity diff --git a/EpikCord/types/interactions.py b/EpikCord/types/interactions.py index 61952263..75f12dd6 100644 --- a/EpikCord/types/interactions.py +++ b/EpikCord/types/interactions.py @@ -156,9 +156,7 @@ class UserOptionData(RequiredOptionData): class ChannelOptionData(RequiredOptionData): - channel_types: NotRequired[ - List[Literal[0, 1, 2, 3, 4, 5, 10, 11, 12, 13, 14, 15]] - ] + channel_types: NotRequired[List[Literal[0, 1, 2, 3, 4, 5, 10, 11, 12, 13, 14, 15]]] class RoleOptionData(RequiredOptionData): diff --git a/EpikCord/user.py b/EpikCord/user.py index 75e5212f..8204ec3d 100644 --- a/EpikCord/user.py +++ b/EpikCord/user.py @@ -22,10 +22,6 @@ def __init__(self, client: Client, data: UserData): self.verified = data.get("verified") self.email = data.get("email") self.flags = instance_or_none(UserFlags, data.get("flags")) - self.premium_type = instance_or_none( - PremiumType, data.get("premium_type") - ) - self.public_flags = instance_or_none( - UserFlags, data.get("public_flags") - ) + self.premium_type = instance_or_none(PremiumType, data.get("premium_type")) + self.public_flags = instance_or_none(UserFlags, data.get("public_flags")) self._data: UserData = data diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py index a1417ef7..01ba1a58 100644 --- a/EpikCord/utils/__init__.py +++ b/EpikCord/utils/__init__.py @@ -23,12 +23,7 @@ log_request, singleton, ) -from .types import ( - AsyncFunction, - IdentifyCommand, - IdentifyData, - SendingAttachmentData, -) +from .types import AsyncFunction, IdentifyCommand, IdentifyData, SendingAttachmentData __all__ = ( "HTTPCodes", diff --git a/EpikCord/utils/loose.py b/EpikCord/utils/loose.py index faa1f69d..216ac333 100644 --- a/EpikCord/utils/loose.py +++ b/EpikCord/utils/loose.py @@ -109,8 +109,7 @@ async def log_request(res, body: Optional[dict] = None): """Logs information about the request.""" logger = getLogger("EpikCord.http") messages = [ - f"Sent a {res.method} to {res.url} " - f"and got a {res.status} response. ", + f"Sent a {res.method} to {res.url} " f"and got a {res.status} response. ", f"Content-Type: {res.headers['Content-Type']} ", ] @@ -130,9 +129,7 @@ async def log_request(res, body: Optional[dict] = None): logger.debug("".join(messages)) -def add_file( - form: aiohttp.FormData, file: File, i: int -) -> SendingAttachmentData: +def add_file(form: aiohttp.FormData, file: File, i: int) -> SendingAttachmentData: form.add_field( f"files[{i}]", file.contents, @@ -171,6 +168,5 @@ def localization_list_to_dict( localizations: List[Localization], ) -> Dict[str, str]: return { - localization.locale.value: localization.value - for localization in localizations + localization.locale.value: localization.value for localization in localizations } From 2dc987c98e95f4b1bcaba869809e801dcbe3426b Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Fri, 16 Jun 2023 17:54:01 +0100 Subject: [PATCH 311/335] Log when waiting for events --- EpikCord/client/websocket.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 2be5a58c..948dae5d 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -118,6 +118,8 @@ def wait_for( self.wait_for_events[opcode.value].append(event) elif name: self.wait_for_events[name].append(event) + + logger.debug("Waiting for event %s", name or opcode) return asyncio.wait_for(event.future, timeout=timeout) From 706dcc6645cfde077751c98312c20e85c3211051 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sat, 17 Jun 2023 08:47:51 +0100 Subject: [PATCH 312/335] Change resuming method --- EpikCord/client/websocket.py | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 948dae5d..12fb850d 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -199,11 +199,16 @@ async def heartbeat(self, *, forced: Optional[bool] = False): await self.send_json({"op": OpCode.HEARTBEAT, "d": self.client.sequence}) start = perf_counter_ns() - - await self.wait_for( - opcode=OpCode.HEARTBEAT_ACK, timeout=self.client.heartbeat_interval - ) - + try: + await self.wait_for( + opcode=OpCode.HEARTBEAT_ACK, timeout=self.client.heartbeat_interval + ) + except asyncio.TimeoutError: + logger.warning("Heartbeat ACK not received in time.") + if self.client.ws: + await self.client.ws.close() + await self.client.connect(resume=True) + return end = perf_counter_ns() self.client._heartbeats.append(end - start) @@ -238,11 +243,6 @@ async def invalid_session(self, event: InvalidSessionEvent): await self.client.connect(resume=resumable) - if resumable: - await self.resume() - else: - await self.identify() - async def _handle_wait_for(self, event: Dict[str, Any]): """Set the Futures for wait_for events.""" values: List[Union[str, int]] = [] @@ -270,6 +270,9 @@ async def handle(self, message: DiscordWSMessage): return await self._handle_wait_for(event) + if self.client._resuming and event["op"] == OpCode.HELLO: + await self.resume() + return await self.opcode_mapping[event["op"]](event) @@ -282,7 +285,6 @@ def __init__(self, *, data, msg_type, extra): def json(self) -> Any: return json.loads(self.data) - class GatewayWebSocket(aiohttp.ClientWebSocketResponse): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -339,6 +341,7 @@ def __init__( self.heartbeat_interval: Optional[float] = None self._heartbeats: List[int] = [] self._heartbeat_task: Optional[asyncio.Task] = None + self._resuming: bool = False self.event = self.event_handler.event @@ -352,6 +355,8 @@ async def connect(self, *, resume: bool = False): if not self._gateway_url and not resume: self._gateway_url = await self.http.get_gateway() + self._resuming = resume + version = self.http.version.value url = ( @@ -373,10 +378,6 @@ async def forever_reset(): self._rate_limiter_task = asyncio.create_task(forever_reset()) - if resume: - await self.event_handler.wait_for(opcode=OpCode.HELLO) - await self.event_handler.resume() - async for message in self.ws: logger.debug("Received message: %s", message.json()) await self.event_handler.handle(message) # type: ignore From 596ce24bb33fff636eb625057d09b1423087a0b0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 17 Jun 2023 07:48:36 +0000 Subject: [PATCH 313/335] :art: Format code automatically --- EpikCord/client/websocket.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 12fb850d..ba2a4770 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -118,7 +118,7 @@ def wait_for( self.wait_for_events[opcode.value].append(event) elif name: self.wait_for_events[name].append(event) - + logger.debug("Waiting for event %s", name or opcode) return asyncio.wait_for(event.future, timeout=timeout) @@ -285,6 +285,7 @@ def __init__(self, *, data, msg_type, extra): def json(self) -> Any: return json.loads(self.data) + class GatewayWebSocket(aiohttp.ClientWebSocketResponse): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) From 0df218323bd237ce23d94a22cd0b0bba1f1ffb54 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sat, 17 Jun 2023 11:00:05 +0100 Subject: [PATCH 314/335] Add return typehint for SubCommandGroup.command --- EpikCord/client/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EpikCord/client/commands.py b/EpikCord/client/commands.py index 0e035ecf..352ad251 100644 --- a/EpikCord/client/commands.py +++ b/EpikCord/client/commands.py @@ -531,7 +531,7 @@ def command( description_localizations: Optional[List[Localization]] = None, options: Optional[List[BaseApplicationCommandOption]] = None, ): - def add_command(func): + def add_command(func) -> SubCommand: command = SubCommand( name, description, From 20bd873339a8e7ad714586648db8e967f83aa72e Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sat, 17 Jun 2023 13:26:46 +0100 Subject: [PATCH 315/335] Log when closing websocket --- EpikCord/client/websocket.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index ba2a4770..f0e62e06 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -311,6 +311,9 @@ async def receive(self, *args, **kwargs): ) async def close(self, *, code: int = 4000, message: bytes = b"") -> bool: + if self._closed: + return False + logger.debug("Closing websocket with code %s and message %s", code, message) return await super().close(code=code, message=message) From 1fc654ee9ed66a306eee0be078f01c59307a439a Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sat, 17 Jun 2023 13:29:52 +0100 Subject: [PATCH 316/335] Check for MsgType --- EpikCord/client/websocket.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index f0e62e06..cf10cee3 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any, DefaultDict, Dict, List, Optional, Union import aiohttp +from aiohttp import WSMsgType from discord_typings import HelloEvent, InvalidSessionEvent, ReadyData from ..exceptions import ClosedWebSocketConnection @@ -384,9 +385,16 @@ async def forever_reset(): async for message in self.ws: logger.debug("Received message: %s", message.json()) - await self.event_handler.handle(message) # type: ignore - - await self.handle_close() + if message.type == WSMsgType.TEXT: + await self.event_handler.handle(message) # type: ignore + elif message.type in ( + WSMsgType.CLOSE, + WSMsgType.CLOSED, + WSMsgType.CLOSING, + WSMsgType.ERROR, + ): + await self.handle_close() + break async def handle_close(self): if not self.ws: From ec4e274794baec3ae7a3607bca4e1a7d14a01853 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Wed, 21 Jun 2023 18:39:58 +0100 Subject: [PATCH 317/335] Add Type safety --- EpikCord/client/client.py | 5 +++-- EpikCord/client/websocket.py | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index fe173e7e..3c2fdba6 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -97,7 +97,7 @@ def stop_loop_on_completion(f: asyncio.Future): cleanup_loop(loop) async def close(self): - logger.critical("Closing connections") + logger.critical("Closing connection") if self.ws and not self.ws.closed: if self._heartbeat_task: @@ -108,7 +108,8 @@ async def close(self): if not self.http.session.closed: await self.http.session.close() - self.rate_limiter.reset.cancel() + if self._rate_limiter_task: + self._rate_limiter_task.cancel() async def change_presence(self, presence: Presence): if not self.ws: diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index cf10cee3..d1394725 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -274,6 +274,7 @@ async def handle(self, message: DiscordWSMessage): if self.client._resuming and event["op"] == OpCode.HELLO: await self.resume() return + await self.opcode_mapping[event["op"]](event) From af0048ef66b991729570706b44536977f1f7d3ee Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Wed, 21 Jun 2023 18:42:50 +0100 Subject: [PATCH 318/335] Move resume logic to hello event handler --- EpikCord/client/websocket.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index d1394725..025e3219 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -173,7 +173,13 @@ async def hello(self, event: HelloEvent): """Handle the hello event (OPCODE 10).""" data = event["d"] self.client.heartbeat_interval = data["heartbeat_interval"] / 1000 - await self.identify() + if not self.client._resuming: + await self.identify() + else: + await self.resume() + + if self.client._heartbeat_task: + self.client._heartbeat_task.cancel() async def heartbeat_task(): while True: @@ -210,6 +216,7 @@ async def heartbeat(self, *, forced: Optional[bool] = False): await self.client.ws.close() await self.client.connect(resume=True) return + end = perf_counter_ns() self.client._heartbeats.append(end - start) @@ -271,9 +278,6 @@ async def handle(self, message: DiscordWSMessage): return await self._handle_wait_for(event) - if self.client._resuming and event["op"] == OpCode.HELLO: - await self.resume() - return await self.opcode_mapping[event["op"]](event) From 2a8fbffdd368c34835427024e7353b7f58b63612 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Wed, 21 Jun 2023 18:47:25 +0100 Subject: [PATCH 319/335] Log if client closing or server --- EpikCord/client/websocket.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 025e3219..1bd43306 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -173,6 +173,7 @@ async def hello(self, event: HelloEvent): """Handle the hello event (OPCODE 10).""" data = event["d"] self.client.heartbeat_interval = data["heartbeat_interval"] / 1000 + if not self.client._resuming: await self.identify() else: @@ -316,10 +317,17 @@ async def receive(self, *args, **kwargs): data=message, msg_type=ws_message.type, extra=ws_message.extra ) - async def close(self, *, code: int = 4000, message: bytes = b"") -> bool: + async def close( + self, *, code: int = 4000, message: bytes = b"", client_triggered: bool = False + ) -> bool: if self._closed: return False - logger.debug("Closing websocket with code %s and message %s", code, message) + logger.debug( + "Closing websocket with code %s and message %s. %s", + code, + message, + f"Client triggered: {client_triggered}" if client_triggered else "", + ) return await super().close(code=code, message=message) From 6b479814ff605a84f9b5559b6958671871c53815 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Wed, 21 Jun 2023 18:49:49 +0100 Subject: [PATCH 320/335] Add reason to closing --- EpikCord/client/client.py | 2 +- EpikCord/client/websocket.py | 16 +++++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index 3c2fdba6..c9b2338e 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -103,7 +103,7 @@ async def close(self): if self._heartbeat_task: self._heartbeat_task.cancel() self._heartbeat_task = None - await self.ws.close() + await self.ws.close(client_triggered=True) if not self.http.session.closed: await self.http.session.close() diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 1bd43306..e7664371 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -214,7 +214,7 @@ async def heartbeat(self, *, forced: Optional[bool] = False): except asyncio.TimeoutError: logger.warning("Heartbeat ACK not received in time.") if self.client.ws: - await self.client.ws.close() + await self.client.ws.close(client_triggered=True, reason=f"Heartbeat ACK not received in {self.client.heartbeat_interval}s.") await self.client.connect(resume=True) return @@ -239,7 +239,7 @@ async def reconnect(self, _): if self.client.ws: if self.client._heartbeat_task: self.client._heartbeat_task.cancel() - await self.client.ws.close() + await self.client.ws.close(client_triggered=True, reason="OpCode 7 was received.") await self.client.connect(resume=True) async def invalid_session(self, event: InvalidSessionEvent): @@ -248,7 +248,7 @@ async def invalid_session(self, event: InvalidSessionEvent): if self.client.ws: if self.client._heartbeat_task: self.client._heartbeat_task.cancel() - await self.client.ws.close() + await self.client.ws.close(client_triggered=True, reason="OpCode 9 was received.") await self.client.connect(resume=resumable) @@ -318,15 +318,21 @@ async def receive(self, *args, **kwargs): ) async def close( - self, *, code: int = 4000, message: bytes = b"", client_triggered: bool = False + self, + *, + code: int = 4000, + message: bytes = b"", + client_triggered: bool = False, + reason: str = "", ) -> bool: if self._closed: return False logger.debug( - "Closing websocket with code %s and message %s. %s", + "Closing websocket with code %s and message %s. %s. %s", code, message, f"Client triggered: {client_triggered}" if client_triggered else "", + f"Reason: {reason}" if reason else "", ) return await super().close(code=code, message=message) From dcabf0254fb2d8256d2f12bd7e18973e08b825cb Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Wed, 21 Jun 2023 18:50:13 +0100 Subject: [PATCH 321/335] Format with black --- EpikCord/client/websocket.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index e7664371..4d81d0fc 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -214,7 +214,10 @@ async def heartbeat(self, *, forced: Optional[bool] = False): except asyncio.TimeoutError: logger.warning("Heartbeat ACK not received in time.") if self.client.ws: - await self.client.ws.close(client_triggered=True, reason=f"Heartbeat ACK not received in {self.client.heartbeat_interval}s.") + await self.client.ws.close( + client_triggered=True, + reason=f"Heartbeat ACK not received in {self.client.heartbeat_interval}s.", + ) await self.client.connect(resume=True) return @@ -239,7 +242,9 @@ async def reconnect(self, _): if self.client.ws: if self.client._heartbeat_task: self.client._heartbeat_task.cancel() - await self.client.ws.close(client_triggered=True, reason="OpCode 7 was received.") + await self.client.ws.close( + client_triggered=True, reason="OpCode 7 was received." + ) await self.client.connect(resume=True) async def invalid_session(self, event: InvalidSessionEvent): @@ -248,7 +253,9 @@ async def invalid_session(self, event: InvalidSessionEvent): if self.client.ws: if self.client._heartbeat_task: self.client._heartbeat_task.cancel() - await self.client.ws.close(client_triggered=True, reason="OpCode 9 was received.") + await self.client.ws.close( + client_triggered=True, reason="OpCode 9 was received." + ) await self.client.connect(resume=resumable) From a2e18c535fde5d4983cf5abe2b4534a40791635c Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Wed, 21 Jun 2023 18:55:24 +0100 Subject: [PATCH 322/335] Log when wait_for event completed --- EpikCord/client/websocket.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 4d81d0fc..695b7b9f 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -56,7 +56,9 @@ def __init__( class GatewayEventHandler: def __init__(self, client: WebSocketClient): self.client = client - self.wait_for_events: DefaultDict[Union[str, int], List] = defaultdict(list) + self.wait_for_events: DefaultDict[ + Union[str, int], List[WaitForEvent] + ] = defaultdict(list) self.events: DefaultDict[str, List[AsyncFunction]] = defaultdict(list) self.opcode_mapping: Dict[OpCode, AsyncFunction] = { OpCode.HEARTBEAT: partial(self.heartbeat, forced=True), @@ -275,7 +277,9 @@ async def _handle_wait_for(self, event: Dict[str, Any]): await wait_for_event.check(event) except Exception as exception: wait_for_event.future.set_exception(exception) + wait_for_event.future.set_result(event) + logger.debug("Set result for %s with data %s", value, event) self.wait_for_events[value].remove(wait_for_event) async def handle(self, message: DiscordWSMessage): From 4daebe3360ce6dcb6adfe6750cf5186322d43f3b Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Wed, 21 Jun 2023 18:59:05 +0100 Subject: [PATCH 323/335] Fix line too long issue --- EpikCord/client/websocket.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 695b7b9f..bc43fffb 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -218,7 +218,10 @@ async def heartbeat(self, *, forced: Optional[bool] = False): if self.client.ws: await self.client.ws.close( client_triggered=True, - reason=f"Heartbeat ACK not received in {self.client.heartbeat_interval}s.", + reason=( + "Heartbeat ACK not received in " + f"{self.client.heartbeat_interval}s." + ), ) await self.client.connect(resume=True) return @@ -411,6 +414,8 @@ async def forever_reset(): await asyncio.sleep(60) await self.rate_limiter.reset() + if self._rate_limiter_task: + self._rate_limiter_task.cancel() self._rate_limiter_task = asyncio.create_task(forever_reset()) async for message in self.ws: From b36713e7ee7b75464c3771e18f03e759ab479f73 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Wed, 21 Jun 2023 19:04:24 +0100 Subject: [PATCH 324/335] Account for binary messages --- EpikCord/client/websocket.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index bc43fffb..2a6faa18 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -130,8 +130,11 @@ async def send_json(self, payload: Any): if not self.client.ws: logger.error("Tried to send a payload without a websocket connection.") return + await self.client.rate_limiter.tick() + logger.debug("Sending %s to Gateway", payload) + for k, v in payload.items(): if isinstance(v, OpCode): payload[k] = v.value @@ -173,6 +176,7 @@ async def identify(self): async def hello(self, event: HelloEvent): """Handle the hello event (OPCODE 10).""" + data = event["d"] self.client.heartbeat_interval = data["heartbeat_interval"] / 1000 @@ -417,10 +421,14 @@ async def forever_reset(): if self._rate_limiter_task: self._rate_limiter_task.cancel() self._rate_limiter_task = asyncio.create_task(forever_reset()) - async for message in self.ws: - logger.debug("Received message: %s", message.json()) - if message.type == WSMsgType.TEXT: + logger.debug( + "Received message types: %s. Data: %s", message.type, message.json() + ) + if message.type in ( + WSMsgType.BINARY, + WSMsgType.TEXT + ): await self.event_handler.handle(message) # type: ignore elif message.type in ( WSMsgType.CLOSE, From d2cc81edc7c7bcb3f791b363470d4b4cd71610ce Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 21 Jun 2023 18:05:35 +0000 Subject: [PATCH 325/335] :art: Format code automatically --- EpikCord/client/websocket.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 2a6faa18..bf58c681 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -425,10 +425,7 @@ async def forever_reset(): logger.debug( "Received message types: %s. Data: %s", message.type, message.json() ) - if message.type in ( - WSMsgType.BINARY, - WSMsgType.TEXT - ): + if message.type in (WSMsgType.BINARY, WSMsgType.TEXT): await self.event_handler.handle(message) # type: ignore elif message.type in ( WSMsgType.CLOSE, From 5467b2640682b01bd9d99cc2ddaa758ca18d548f Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Wed, 21 Jun 2023 19:10:21 +0100 Subject: [PATCH 326/335] Remove weird log --- EpikCord/client/client.py | 1 - EpikCord/client/websocket.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index c9b2338e..dc5f55bb 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -79,7 +79,6 @@ async def runner(): try: await self.connect() finally: - logger.critical("Final clause triggered.") await self.close() def stop_loop_on_completion(f: asyncio.Future): diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 2a6faa18..1f6db7e3 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -437,7 +437,7 @@ async def forever_reset(): WSMsgType.ERROR, ): await self.handle_close() - break + async def handle_close(self): if not self.ws: From 39e0bdd7ecc9480f231f5a8fac233608305275c1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 21 Jun 2023 18:11:12 +0000 Subject: [PATCH 327/335] :art: Format code automatically --- EpikCord/client/websocket.py | 1 - 1 file changed, 1 deletion(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 43745319..ae139e45 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -434,7 +434,6 @@ async def forever_reset(): WSMsgType.ERROR, ): await self.handle_close() - async def handle_close(self): if not self.ws: From 211e0cb29af3ab52d8ca717150507c926bacbfa1 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Wed, 21 Jun 2023 19:11:15 +0100 Subject: [PATCH 328/335] Return when handling close --- EpikCord/client/websocket.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 43745319..5a455f36 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -451,7 +451,7 @@ async def handle_close(self): GatewayCloseCode.NORMAL_CLOSURE, GatewayCloseCode.ABNORMAL_CLOSURE, ): - await self.connect(resume=False) + return await self.connect(resume=False) try: gce_code = GatewayCloseCode(close_code) From f817db8844d5e1133b640bb5b96443a81cc00bdd Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Wed, 21 Jun 2023 19:15:34 +0100 Subject: [PATCH 329/335] Reset HTTP when reconnecting --- EpikCord/client/websocket.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 0e4bafb0..33e20750 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -394,6 +394,13 @@ def latency(self) -> Optional[float]: return sum(self._heartbeats) / len(self._heartbeats) async def connect(self, *, resume: bool = False): + if self.http.session.closed: + logger.debug( + "HTTP session is closed, creating a new one for the websocket." + ) + version = self.http.version + self.http = HTTPClient(self.token, version=version) + if not self._gateway_url and not resume: self._gateway_url = await self.http.get_gateway() From 1a6cf73794d346a86aca2abc3b9ddb473971755d Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Wed, 21 Jun 2023 19:23:00 +0100 Subject: [PATCH 330/335] Fix circular import --- EpikCord/__init__.py | 3 ++- EpikCord/client/__init__.py | 3 +-- EpikCord/client/client.py | 18 +++++++----------- EpikCord/client/http.py | 13 +++++++++++++ EpikCord/client/websocket.py | 5 +++-- EpikCord/utils/__init__.py | 2 ++ EpikCord/utils/loose.py | 9 +++++++++ 7 files changed, 37 insertions(+), 16 deletions(-) diff --git a/EpikCord/__init__.py b/EpikCord/__init__.py index b597336e..eef99932 100644 --- a/EpikCord/__init__.py +++ b/EpikCord/__init__.py @@ -1,6 +1,6 @@ __version__ = "0.6.0" -from .client import APIVersion, Client, HTTPClient, TokenStore +from .client import APIVersion, Client, HTTPClient from .exceptions import ( BadRequest, EpikCordException, @@ -13,6 +13,7 @@ from .ext import Task, task from .file import File from .flags import ChannelFlags, Flag, Intents, Permissions, SystemChannelFlags +from .utils import TokenStore __all__ = ( "APIVersion", diff --git a/EpikCord/client/__init__.py b/EpikCord/client/__init__.py index 83d5c78b..01cf4c7c 100644 --- a/EpikCord/client/__init__.py +++ b/EpikCord/client/__init__.py @@ -1,9 +1,8 @@ -from .client import Client, TokenStore +from .client import Client from .http import APIVersion, HTTPClient __all__ = ( "APIVersion", "Client", - "TokenStore", "HTTPClient", ) diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index dc5f55bb..46e85485 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -1,12 +1,14 @@ +from __future__ import annotations + import asyncio from logging import getLogger -from typing import List, Optional, Union +from typing import List, Optional, Union, TYPE_CHECKING from ..flags import Intents, Permissions from ..locales import Localization from ..presence import Presence from ..types import OpCode -from ..utils import AsyncFunction, cleanup_loop, singleton +from ..utils import AsyncFunction, cleanup_loop from .commands import ( ApplicationCommandOption, ApplicationCommandType, @@ -17,16 +19,10 @@ from .http import APIVersion, HTTPClient from .websocket import WebSocketClient -logger = getLogger("EpikCord.client") - +if TYPE_CHECKING: + from ..utils import TokenStore -@singleton -class TokenStore: - def __init__(self, token: str): - self.value: str = token - - def remove_from(self, string: str) -> str: - return string.replace(self.value, "[REMOVED TOKEN]") +logger = getLogger("EpikCord.client") class Client(WebSocketClient): diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 8066d007..684eccf0 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -368,3 +368,16 @@ async def get_gateway_bot(self) -> GatewayBotData: response = await self.request(Route("GET", "/gateway/bot")) data = await response.json() return GatewayBotData(data) + + def clone(self) -> "HTTPClient": + """Clone the HTTPClient. + + Returns + ------- + HTTPClient + The cloned HTTPClient. + """ + return HTTPClient( + token=self.token, + version=self.version + ) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 33e20750..3e50ff6b 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -31,9 +31,10 @@ import json # type: ignore if TYPE_CHECKING: - from .client import TokenStore + from ..utils import TokenStore from .http import HTTPClient + logger = getLogger("EpikCord.websocket") @@ -399,7 +400,7 @@ async def connect(self, *, resume: bool = False): "HTTP session is closed, creating a new one for the websocket." ) version = self.http.version - self.http = HTTPClient(self.token, version=version) + self.http = self.http.clone() if not self._gateway_url and not resume: self._gateway_url = await self.http.get_gateway() diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py index 01ba1a58..db5b02c3 100644 --- a/EpikCord/utils/__init__.py +++ b/EpikCord/utils/__init__.py @@ -22,6 +22,7 @@ localization_list_to_dict, log_request, singleton, + TokenStore ) from .types import AsyncFunction, IdentifyCommand, IdentifyData, SendingAttachmentData @@ -51,4 +52,5 @@ "instance_or_none", "int_or_none", "localization_list_to_dict", + "TokenStore", ) diff --git a/EpikCord/utils/loose.py b/EpikCord/utils/loose.py index 216ac333..1832b734 100644 --- a/EpikCord/utils/loose.py +++ b/EpikCord/utils/loose.py @@ -170,3 +170,12 @@ def localization_list_to_dict( return { localization.locale.value: localization.value for localization in localizations } + +@singleton +class TokenStore: + def __init__(self, token: str): + self.value: str = token + + def remove_from(self, string: str) -> str: + return string.replace(self.value, "[REMOVED TOKEN]") + From b56d2821b927cfe3b0c25e8bdd83f86c3ee8f0fe Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 21 Jun 2023 18:23:43 +0000 Subject: [PATCH 331/335] :art: Format code automatically --- EpikCord/client/client.py | 2 +- EpikCord/client/http.py | 5 +---- EpikCord/utils/__init__.py | 2 +- EpikCord/utils/loose.py | 2 +- 4 files changed, 4 insertions(+), 7 deletions(-) diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index 46e85485..22a617a1 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -2,7 +2,7 @@ import asyncio from logging import getLogger -from typing import List, Optional, Union, TYPE_CHECKING +from typing import TYPE_CHECKING, List, Optional, Union from ..flags import Intents, Permissions from ..locales import Localization diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 684eccf0..752af175 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -377,7 +377,4 @@ def clone(self) -> "HTTPClient": HTTPClient The cloned HTTPClient. """ - return HTTPClient( - token=self.token, - version=self.version - ) + return HTTPClient(token=self.token, version=self.version) diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py index db5b02c3..93a3c59b 100644 --- a/EpikCord/utils/__init__.py +++ b/EpikCord/utils/__init__.py @@ -10,6 +10,7 @@ TeamMembershipState, ) from .loose import ( + TokenStore, add_file, cancel_tasks, clean_url, @@ -22,7 +23,6 @@ localization_list_to_dict, log_request, singleton, - TokenStore ) from .types import AsyncFunction, IdentifyCommand, IdentifyData, SendingAttachmentData diff --git a/EpikCord/utils/loose.py b/EpikCord/utils/loose.py index 1832b734..14f8c146 100644 --- a/EpikCord/utils/loose.py +++ b/EpikCord/utils/loose.py @@ -171,6 +171,7 @@ def localization_list_to_dict( localization.locale.value: localization.value for localization in localizations } + @singleton class TokenStore: def __init__(self, token: str): @@ -178,4 +179,3 @@ def __init__(self, token: str): def remove_from(self, string: str) -> str: return string.replace(self.value, "[REMOVED TOKEN]") - From 28c2bcd7eef4a79dccce985c8c8c2eba85660c8c Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Sun, 25 Jun 2023 22:50:02 +0100 Subject: [PATCH 332/335] Current state --- EpikCord/client/client.py | 16 +++-- EpikCord/client/http.py | 5 +- EpikCord/client/websocket.py | 42 ++----------- EpikCord/client/ws_close_handler.py | 96 ----------------------------- EpikCord/utils/__init__.py | 2 +- EpikCord/utils/loose.py | 2 +- 6 files changed, 20 insertions(+), 143 deletions(-) delete mode 100644 EpikCord/client/ws_close_handler.py diff --git a/EpikCord/client/client.py b/EpikCord/client/client.py index 46e85485..2a397b37 100644 --- a/EpikCord/client/client.py +++ b/EpikCord/client/client.py @@ -92,19 +92,25 @@ def stop_loop_on_completion(f: asyncio.Future): cleanup_loop(loop) async def close(self): - logger.critical("Closing connection") - if self.ws and not self.ws.closed: + logger.debug( + "Closing connection to gateway with code %s", + self.ws.close_code or "unknown", + ) if self._heartbeat_task: self._heartbeat_task.cancel() self._heartbeat_task = None - await self.ws.close(client_triggered=True) + await self.ws.close() + + # TODO: Fix Close Session + # This is currently not done because it raises errors - if not self.http.session.closed: - await self.http.session.close() + # if not self.http.session.closed: + # await self.http.session.close() if self._rate_limiter_task: self._rate_limiter_task.cancel() + self._rate_limiter_task = None async def change_presence(self, presence: Presence): if not self.ws: diff --git a/EpikCord/client/http.py b/EpikCord/client/http.py index 684eccf0..752af175 100644 --- a/EpikCord/client/http.py +++ b/EpikCord/client/http.py @@ -377,7 +377,4 @@ def clone(self) -> "HTTPClient": HTTPClient The cloned HTTPClient. """ - return HTTPClient( - token=self.token, - version=self.version - ) + return HTTPClient(token=self.token, version=self.version) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 3e50ff6b..c06a4dd9 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -20,7 +20,6 @@ from ..types import GatewayCloseCode, IdentifyCommand, OpCode from ..utils import AsyncFunction from .rate_limit_tools import GatewayRateLimiter -from .ws_close_handler import CloseHandlerLog, CloseHandlerRaise, close_dispatcher _ORJSON = find_spec("orjson") @@ -252,10 +251,11 @@ async def reconnect(self, _): if self.client.ws: if self.client._heartbeat_task: self.client._heartbeat_task.cancel() + self.client._heartbeat_task = None await self.client.ws.close( client_triggered=True, reason="OpCode 7 was received." ) - await self.client.connect(resume=True) + return await self.client.connect(resume=True) async def invalid_session(self, event: InvalidSessionEvent): """Handle the INVALID_SESSION event (OPCODE 9).""" @@ -267,7 +267,7 @@ async def invalid_session(self, event: InvalidSessionEvent): client_triggered=True, reason="OpCode 9 was received." ) - await self.client.connect(resume=resumable) + return await self.client.connect(resume=resumable) async def _handle_wait_for(self, event: Dict[str, Any]): """Set the Futures for wait_for events.""" @@ -346,13 +346,6 @@ async def close( ) -> bool: if self._closed: return False - logger.debug( - "Closing websocket with code %s and message %s. %s. %s", - code, - message, - f"Client triggered: {client_triggered}" if client_triggered else "", - f"Reason: {reason}" if reason else "", - ) return await super().close(code=code, message=message) @@ -395,13 +388,6 @@ def latency(self) -> Optional[float]: return sum(self._heartbeats) / len(self._heartbeats) async def connect(self, *, resume: bool = False): - if self.http.session.closed: - logger.debug( - "HTTP session is closed, creating a new one for the websocket." - ) - version = self.http.version - self.http = self.http.clone() - if not self._gateway_url and not resume: self._gateway_url = await self.http.get_gateway() @@ -428,7 +414,9 @@ async def forever_reset(): if self._rate_limiter_task: self._rate_limiter_task.cancel() + self._rate_limiter_task = asyncio.create_task(forever_reset()) + async for message in self.ws: logger.debug( "Received message types: %s. Data: %s", message.type, message.json() @@ -442,6 +430,7 @@ async def forever_reset(): WSMsgType.ERROR, ): await self.handle_close() + await self.ws.close() async def handle_close(self): if not self.ws: @@ -459,22 +448,3 @@ async def handle_close(self): GatewayCloseCode.ABNORMAL_CLOSURE, ): return await self.connect(resume=False) - - try: - gce_code = GatewayCloseCode(close_code) - ch_ins = close_dispatcher[gce_code] - - except (ValueError, KeyError) as e: - raise ClosedWebSocketConnection( - f"Connection has been closed with code {self.ws.close_code}" - ) from e - - if isinstance(ch_ins, CloseHandlerRaise): - raise ch_ins.exception(ch_ins.message) - - if isinstance(ch_ins, CloseHandlerLog): - report_msg = "\n\nReport this immediately" * ch_ins.need_report - logger.critical(ch_ins.message + report_msg) - - if ch_ins.resumable: - await self.connect(resume=True) diff --git a/EpikCord/client/ws_close_handler.py b/EpikCord/client/ws_close_handler.py deleted file mode 100644 index 02da8f1a..00000000 --- a/EpikCord/client/ws_close_handler.py +++ /dev/null @@ -1,96 +0,0 @@ -import logging -from functools import partial -from typing import Dict, Optional, Type - -from ..exceptions import ( - DisallowedIntents, - GatewayRateLimited, - InvalidIntents, - InvalidToken, - ShardingRequired, -) -from ..types import GatewayCloseCode - -logger = logging.getLogger("EpikCord.websocket") - - -class CloseHandler: - def __init__(self, resumable: bool = True): - self.resumable = resumable - - -class CloseHandlerRaise(CloseHandler): - def __init__(self, exception: Type[Exception], message: Optional[str] = None): - super().__init__(resumable=False) - self.exception = exception - self.message = message - - -class CloseHandlerLog(CloseHandler): - def __init__(self, message: str, resumable: bool = True, need_report: bool = False): - super().__init__(resumable=resumable) - self.message = message - self.need_report = need_report - - -_ignore = CloseHandler() -CloseHandlerLogReported = partial(CloseHandlerLog, need_report=True) - -_close_disallowed_intents = CloseHandlerRaise( - DisallowedIntents, - "You cannot use privileged intents with this token, go to the developer" - " portal and allow the privileged intents needed. ", -) -_close_authentication_failed = CloseHandlerRaise( - InvalidToken, "The token you provided is invalid." -) -_close_rate_limited = CloseHandlerRaise( - GatewayRateLimited, - "You've been rate limited. Try again in a few minutes.", -) -_close_sharding_required = CloseHandlerRaise( - ShardingRequired, "You need to shard the bot." -) -_close_invalid_api_version = CloseHandlerRaise( - DeprecationWarning, - "The gateway you're connecting to is deprecated and does not work," - " update EpikCord.", -) -_close_invalid_intents = CloseHandlerRaise( - InvalidIntents, "The intents you provided are invalid." -) -_close_unknown_opcode = CloseHandlerLogReported( - "EpikCord.py sent an invalid OPCODE to the Gateway." -) -_close_decode_error = CloseHandlerLogReported( - "EpikCord.py sent an invalid payload to the Gateway." -) -_close_not_authenticated = CloseHandlerLog( - "EpikCord.py has sent a payload prior to identifying.", - resumable=False, - need_report=True, -) -_close_already_authenticated = CloseHandlerLogReported( - "EpikCord.py tried to authenticate again." -) -_close_invalid_sequence = CloseHandlerLogReported( - "EpikCord.py sent an invalid sequence number." -) -_close_session_timed_out = CloseHandlerLog("Session timed out.") - -close_dispatcher: Dict[GatewayCloseCode, CloseHandler] = { - GatewayCloseCode.ABNORMAL_CLOSURE: _ignore, - GatewayCloseCode.UNKNOWN_ERROR: _ignore, - GatewayCloseCode.DISALLOWED_INTENTS: _close_disallowed_intents, - GatewayCloseCode.AUTHENTICATION_FAILED: _close_authentication_failed, - GatewayCloseCode.RATE_LIMITED: _close_rate_limited, - GatewayCloseCode.SHARDING_REQUIRED: _close_sharding_required, - GatewayCloseCode.INVALID_API_VERSION: _close_invalid_api_version, - GatewayCloseCode.INVALID_INTENTS: _close_invalid_intents, - GatewayCloseCode.UNKNOWN_OPCODE: _close_unknown_opcode, - GatewayCloseCode.DECODE_ERROR: _close_decode_error, - GatewayCloseCode.NOT_AUTHENTICATED: _close_not_authenticated, - GatewayCloseCode.ALREADY_AUTHENTICATED: _close_already_authenticated, - GatewayCloseCode.INVALID_SEQUENCE: _close_invalid_sequence, - GatewayCloseCode.SESSION_TIMED_OUT: _close_session_timed_out, -} diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py index db5b02c3..27dac7a3 100644 --- a/EpikCord/utils/__init__.py +++ b/EpikCord/utils/__init__.py @@ -22,7 +22,7 @@ localization_list_to_dict, log_request, singleton, - TokenStore + TokenStore, ) from .types import AsyncFunction, IdentifyCommand, IdentifyData, SendingAttachmentData diff --git a/EpikCord/utils/loose.py b/EpikCord/utils/loose.py index 1832b734..14f8c146 100644 --- a/EpikCord/utils/loose.py +++ b/EpikCord/utils/loose.py @@ -171,6 +171,7 @@ def localization_list_to_dict( localization.locale.value: localization.value for localization in localizations } + @singleton class TokenStore: def __init__(self, token: str): @@ -178,4 +179,3 @@ def __init__(self, token: str): def remove_from(self, string: str) -> str: return string.replace(self.value, "[REMOVED TOKEN]") - From 8cf339bd579c04f8b211656eecb93e0067716a32 Mon Sep 17 00:00:00 2001 From: TheUntraceable <73362400+TheUntraceable@users.noreply.github.com> Date: Mon, 24 Jul 2023 17:57:48 +0100 Subject: [PATCH 333/335] CUrrent status --- EpikCord/client/websocket.py | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index c06a4dd9..3cd73bfd 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -14,7 +14,7 @@ from aiohttp import WSMsgType from discord_typings import HelloEvent, InvalidSessionEvent, ReadyData -from ..exceptions import ClosedWebSocketConnection +# from ..exceptions import ClosedWebSocketConnection from ..flags import Intents from ..presence import Presence from ..types import GatewayCloseCode, IdentifyCommand, OpCode @@ -417,20 +417,28 @@ async def forever_reset(): self._rate_limiter_task = asyncio.create_task(forever_reset()) - async for message in self.ws: + while True: + message = await self.ws.receive() + + if not message: + continue + logger.debug( "Received message types: %s. Data: %s", message.type, message.json() ) + if message.type in (WSMsgType.BINARY, WSMsgType.TEXT): - await self.event_handler.handle(message) # type: ignore - elif message.type in ( - WSMsgType.CLOSE, - WSMsgType.CLOSED, - WSMsgType.CLOSING, - WSMsgType.ERROR, - ): - await self.handle_close() - await self.ws.close() + + await self.event_handler.handle(message) + + # elif message.type in ( + # WSMsgType.CLOSE, + # WSMsgType.CLOSED, + # WSMsgType.CLOSING, + # WSMsgType.ERROR, + # ): + # await self.handle_close() + await self.ws.close() async def handle_close(self): if not self.ws: @@ -448,3 +456,5 @@ async def handle_close(self): GatewayCloseCode.ABNORMAL_CLOSURE, ): return await self.connect(resume=False) + + return await self.connect(resume=True) From a4ca2eae261bfed928d07713b04b0cfb218f1f7b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 24 Jul 2023 16:59:08 +0000 Subject: [PATCH 334/335] :art: Format code automatically --- EpikCord/client/websocket.py | 1 - EpikCord/utils/__init__.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index 3cd73bfd..abd4d627 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -428,7 +428,6 @@ async def forever_reset(): ) if message.type in (WSMsgType.BINARY, WSMsgType.TEXT): - await self.event_handler.handle(message) # elif message.type in ( diff --git a/EpikCord/utils/__init__.py b/EpikCord/utils/__init__.py index 27dac7a3..93a3c59b 100644 --- a/EpikCord/utils/__init__.py +++ b/EpikCord/utils/__init__.py @@ -10,6 +10,7 @@ TeamMembershipState, ) from .loose import ( + TokenStore, add_file, cancel_tasks, clean_url, @@ -22,7 +23,6 @@ localization_list_to_dict, log_request, singleton, - TokenStore, ) from .types import AsyncFunction, IdentifyCommand, IdentifyData, SendingAttachmentData From 28f6ebed0205d33c28901095b70d57db16661284 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 26 May 2025 15:08:28 +0000 Subject: [PATCH 335/335] :art: Format code automatically --- EpikCord/client/commands.py | 9 ++++----- EpikCord/client/websocket.py | 6 +++--- EpikCord/exceptions.py | 36 ++++++++++++---------------------- EpikCord/types/interactions.py | 27 +++++++++---------------- 4 files changed, 28 insertions(+), 50 deletions(-) diff --git a/EpikCord/client/commands.py b/EpikCord/client/commands.py index 352ad251..a5e7f540 100644 --- a/EpikCord/client/commands.py +++ b/EpikCord/client/commands.py @@ -51,9 +51,9 @@ def to_dict(self): if self.guild_ids: payload["guild_ids"] = self.guild_ids if self.default_member_permissions: - payload[ - "default_member_permissions" - ] = self.default_member_permissions.value + payload["default_member_permissions"] = ( + self.default_member_permissions.value + ) if self.name_localizations: payload["name_localizations"] = localization_list_to_dict( self.name_localizations @@ -62,8 +62,7 @@ def to_dict(self): return payload -class ClientContextMenuCommand(BaseClientCommand): - ... +class ClientContextMenuCommand(BaseClientCommand): ... class ClientUserCommand(ClientContextMenuCommand): diff --git a/EpikCord/client/websocket.py b/EpikCord/client/websocket.py index abd4d627..4e716a4c 100644 --- a/EpikCord/client/websocket.py +++ b/EpikCord/client/websocket.py @@ -56,9 +56,9 @@ def __init__( class GatewayEventHandler: def __init__(self, client: WebSocketClient): self.client = client - self.wait_for_events: DefaultDict[ - Union[str, int], List[WaitForEvent] - ] = defaultdict(list) + self.wait_for_events: DefaultDict[Union[str, int], List[WaitForEvent]] = ( + defaultdict(list) + ) self.events: DefaultDict[str, List[AsyncFunction]] = defaultdict(list) self.opcode_mapping: Dict[OpCode, AsyncFunction] = { OpCode.HEARTBEAT: partial(self.heartbeat, forced=True), diff --git a/EpikCord/exceptions.py b/EpikCord/exceptions.py index b4a49d76..3d44f885 100644 --- a/EpikCord/exceptions.py +++ b/EpikCord/exceptions.py @@ -2,8 +2,7 @@ from typing import Dict -class EpikCordException(Exception): - ... +class EpikCordException(Exception): ... @dataclass @@ -49,48 +48,37 @@ def extract_errors(self, d, key_path=None): ] -class NotFound(HTTPException): - ... +class NotFound(HTTPException): ... -class Forbidden(HTTPException): - ... +class Forbidden(HTTPException): ... -class Unauthorized(HTTPException): - ... +class Unauthorized(HTTPException): ... -class BadRequest(HTTPException): - ... +class BadRequest(HTTPException): ... -class TooManyRetries(EpikCordException): - ... +class TooManyRetries(EpikCordException): ... -class ClosedWebSocketConnection(EpikCordException): - ... +class ClosedWebSocketConnection(EpikCordException): ... -class DisallowedIntents(ClosedWebSocketConnection): - ... +class DisallowedIntents(ClosedWebSocketConnection): ... -class InvalidIntents(ClosedWebSocketConnection): - ... +class InvalidIntents(ClosedWebSocketConnection): ... -class InvalidToken(ClosedWebSocketConnection): - ... +class InvalidToken(ClosedWebSocketConnection): ... -class GatewayRateLimited(ClosedWebSocketConnection): - ... +class GatewayRateLimited(ClosedWebSocketConnection): ... -class ShardingRequired(ClosedWebSocketConnection): - ... +class ShardingRequired(ClosedWebSocketConnection): ... class UnknownMimeType(EpikCordException): diff --git a/EpikCord/types/interactions.py b/EpikCord/types/interactions.py index 75f12dd6..3c367856 100644 --- a/EpikCord/types/interactions.py +++ b/EpikCord/types/interactions.py @@ -139,36 +139,29 @@ class BaseNumberOptionData(BaseStringIntegerNumberOptionData): max_value: NotRequired[int] -class IntegerOptionData(BaseNumberOptionData): - ... +class IntegerOptionData(BaseNumberOptionData): ... -class NumberOptionData(BaseNumberOptionData): - ... +class NumberOptionData(BaseNumberOptionData): ... -class BooleanOptionData(RequiredOptionData): - ... +class BooleanOptionData(RequiredOptionData): ... -class UserOptionData(RequiredOptionData): - ... +class UserOptionData(RequiredOptionData): ... class ChannelOptionData(RequiredOptionData): channel_types: NotRequired[List[Literal[0, 1, 2, 3, 4, 5, 10, 11, 12, 13, 14, 15]]] -class RoleOptionData(RequiredOptionData): - ... +class RoleOptionData(RequiredOptionData): ... -class AttachmentOptionData(RequiredOptionData): - ... +class AttachmentOptionData(RequiredOptionData): ... -class MentionableOptionData(RequiredOptionData): - ... +class MentionableOptionData(RequiredOptionData): ... ApplicationCommandOptionData = Union[ @@ -199,9 +192,7 @@ class ChatInputCommandPayload(BaseApplicationCommandPayload): options: NotRequired[List[ApplicationCommandOptionData]] -class MessageContextMenuCommandPayload(BaseApplicationCommandPayload): - ... +class MessageContextMenuCommandPayload(BaseApplicationCommandPayload): ... -class UserContextMenuCommandPayload(BaseApplicationCommandPayload): - ... +class UserContextMenuCommandPayload(BaseApplicationCommandPayload): ...