diff --git a/.travis.yml b/.travis.yml index fbb6fdc..42af38e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -23,7 +23,7 @@ script: - pylama . # Unit tests - - python setup.py test + - if [ "$TRAVIS_PULL_REQUEST" = "false" ]; then python setup.py test; fi # Build docs - make -C docs html diff --git a/clamor/__init__.py b/clamor/__init__.py index 79cca6b..c0c18a4 100644 --- a/clamor/__init__.py +++ b/clamor/__init__.py @@ -10,8 +10,10 @@ :license: MIT, see LICENSE for more details. """ +from .gateway import * from .meta import * from .rest import * +from .utils import * import logging diff --git a/clamor/exceptions.py b/clamor/exceptions.py index 903478c..f621397 100644 --- a/clamor/exceptions.py +++ b/clamor/exceptions.py @@ -8,13 +8,17 @@ from asks.response_objects import Response __all__ = ( - 'JSONErrorCode', 'ClamorError', - 'RequestFailed', - 'Unauthorized', + 'EncodingFailed', 'Forbidden', - 'NotFound', + 'GatewayCloseCode', + 'GatewayError', 'Hierarchied', + 'InvalidListener', + 'JSONErrorCode', + 'NotFound', + 'RequestFailed', + 'Unauthorized', ) logger = logging.getLogger(__name__) @@ -147,6 +151,48 @@ def name(self) -> str: return ' '.join(part.capitalize() for part in self._name_.split('_')) +class GatewayCloseCode(IntEnum): + """Enum that holds the possible WebSocket close codes for gateway.""" + + #: Connection was closed gracefully (or heartbeats timed out). + OK = 1000 + #: Connection was closed due to CloudFlare load balancing. + LOAD_BALANCING_CLOSURE = 1001 + #: Random server error. + RANDOM_SERVER_ERROR = 1006 + + #: Unknown error. + UNKNOWN_ERROR = 4000 + #: An invalid opcode or an invalid payload for an opcode was sent. + UNKNOWN_OPCODE = 4001 + #: Server failed to decode a payload. + DECODE_ERROR = 4002 + #: A payload was sent prior to identifying. + NOT_AUTHENTICATED = 4003 + #: The token in the identify payload was incorrect. + AUTHENTICATION_FAILED = 4004 + #: More than one identify payload was sent. + ALREADY_AUTHENTICATED = 4005 + #: Attempted to resume an invalid session. Now unused, Op 9 is sent instead. + INVALID_SESSION_RESUMED = 4006 + #: An invalid sequence was used for resuming. + INVALID_SEQUENCE = 4007 + #: We are being rate limited. + RATE_LIMITED = 4008 + #: The session timed out. + SESSION_TIMEOUT = 4009 + #: Invalid shard was sent in the identify payload. + INVALID_SHARD = 4010 + #: Too many guilds were to be handled by a single connection. + SHARDING_REQUIRED = 4011 + + @property + def name(self) -> str: + """Returns a human-readable version of the enum member's name.""" + + return ' '.join(part.capitalize() for part in self._name_.split('_')) + + class ClamorError(Exception): """Base exception class for any exceptions raised by this library. @@ -291,3 +337,39 @@ class Hierarchied(ClamorError): *Even occurs if the bot has ``Kick/Ban Members`` permissions.* """ pass + + +class GatewayError(ClamorError): + """Base class for every error raised by gateway components. + + Catching this error is not recommended as it mostly + indicates a client or server panic. + """ + pass + + +class EncodingFailed(GatewayError): + """Raised when the encoding or decoding of a message fails. + + Parameters + ---------- + err : Optional[str] + Error message returned by the encoder + data : Optional[Union[dict, str]] + Raw data of the message + """ + + def __init__(self, err: Optional[str], data: Optional[Union[dict, str]] = None): + if data: + error = "Encoding of message {} failed".format(str(data)) + else: + error = "Decoding of gateway message failed" + if error: + error += " with exception {}".format(err) + + super().__init__(error) + + +class InvalidListener(GatewayError): + """Raised by the emitter when a listener is not a coroutine.""" + pass diff --git a/clamor/gateway/__init__.py b/clamor/gateway/__init__.py new file mode 100644 index 0000000..5a7066e --- /dev/null +++ b/clamor/gateway/__init__.py @@ -0,0 +1,4 @@ +from .connector import * +from .encoding import * +from .opcodes import * +from .serialization import * diff --git a/clamor/gateway/connector.py b/clamor/gateway/connector.py new file mode 100644 index 0000000..2742939 --- /dev/null +++ b/clamor/gateway/connector.py @@ -0,0 +1,278 @@ +# -*- coding: utf-8 -*- + +import logging +import zlib +from random import randint +from time import perf_counter +from typing import Any, Union + +import anyio +import anysocks + +from ..exceptions import * +from ..utils import Emitter +from .encoding import ENCODERS +from .opcodes import Opcode +from .serialization import * + +logger = logging.getLogger(__name__) + + +class DiscordWebsocketClient: + """Implements version 6 of the Discord WebSocket Gateway protocol. + + Todo: Add sharding. + + Parameters + ---------- + url : str + URL of the gateway, usually 'wss://gateway.discord.gg`` + encoding : str + Either 'json' or 'etf'; the encoding used in the connection. Defaults to 'json' + zlib_compressed : bool + Indicates if the communication should be compressed. Defaults to false + + Attributes + ---------- + url : str + URL of the gateway + encoder : :class:`clamor.gateway.BaseEncoder` + Used to en-/decode the messages from the gateway. + zlib_compressed : bool + Indicates if the communication should be compressed. + """ + + #: The gateway version to use. + VERSION = 6 + #: Necessary bytes to detect zlib-compressed payloads. + ZLIB_SUFFIX = b'\x00\x00\xff\xff' + + def __init__(self, url: str, **kwargs): + self.encoder = ENCODERS[kwargs.get('encoding', 'json')] + self.zlib_compressed = kwargs.get('zlib_compressed', True) + self._token = None + self.url = self.format_url(url) + + # Sharding + self.shard_id = kwargs.get('shard_id') + self.shard_count = kwargs.get('shard_count') + + # Compression + if self.zlib_compressed: + self.buffer = bytearray() + self.inflator = zlib.decompressobj() + + # WebSocket connection + self._con = None + self._running = False + self._should_reconnect = True + self._task_group = None + + # Connection state + self._session_id = None + self.reconnects = 0 + self._last_sent = perf_counter() + self._last_ack = perf_counter() + self.latency = float('inf') + + # Heartbeat stuff + self.sequence = None + self._interval = 0 + self._heartbeat_acknowledged = True + + # TODO: Rate limiting + + # Event dispatching + self.emitter = Emitter() + self.emitter.add_listener(Opcode.DISPATCH, self._on_dispatch) + self.emitter.add_listener(Opcode.HEARTBEAT, self._on_heartbeat) + self.emitter.add_listener(Opcode.RECONNECT, self._on_reconnect) + self.emitter.add_listener(Opcode.INVALID_SESSION, self._on_invalid_session) + self.emitter.add_listener(Opcode.HELLO, self._on_hello) + self.emitter.add_listener(Opcode.HEARTBEAT_ACK, self._on_heartbeat_ack) + + def format_url(self, url: str) -> str: + url += '?v={}&encoding={}'.format(self.VERSION, self.encoder.TYPE) + if self.zlib_compressed: + url += '&compress=zlib-stream' + + return url + + async def _heartbeat_task(self): + while not self._con.closed and self._running: + await self._do_heartbeat() + await anyio.sleep(self._interval) + + async def _do_heartbeat(self): + if self._heartbeat_acknowledged: + logger.debug('Sending Heartbeat with sequence %s', self.sequence) + await self._send(Opcode.HEARTBEAT, self.sequence) + self._last_sent = perf_counter() + self._heartbeat_acknowledged = False + else: + logger.error('Gateway hasn\'t responded with HEARTBEAT_ACK. Forcing a reconnect.') + await self._con.close(4000, 'Zombied connection!') + + async def _send(self, opcode: Union[Opcode, int, str], data: Any): + logger.debug('Sending %s', data) + payload = { + 'op': opcode.value if isinstance(opcode, Opcode) else int(opcode), + 'd': data, + } + + await self._con.send(self.encoder.encode(payload)) + + async def _on_dispatch(self, event: str, data: dict): + logger.debug('Received Opcode 0 Dispatch for event %s with data %s', event, data) + + # Ready is special and contains critical state information, + # that's why it needs to be handled separately. + if event == 'READY': + if data['v'] != self.VERSION: + raise RuntimeError('Gateway protocol versions do not match') + + if 'shard' in data and data['shard'] != [self.shard_id, self.shard_count]: + raise RuntimeError('Running on wrong shard') + + self._session_id = data['session_id'] + + # TODO: Do actual event dispatching. + + async def _on_heartbeat(self, _): + logger.debug('Heartbeat requested by the Discord gateway') + await self._do_heartbeat() + self._last_sent = perf_counter() + + async def _on_reconnect(self, _): + logger.debug('Received Opcode 7 Reconnect, forcing a reconnect.') + self._session_id = None + self.sequence = None + await self._con.close(1000) + + async def _on_invalid_session(self, _): + logger.debug('Received Opcode 9 Invalid Session, forcing a reconnect.') + self._session_id = None + self.sequence = None + await self._con.close(1000) + + async def _on_hello(self, data: dict): + self._interval = data['heartbeat_interval'] / 1000 + logger.debug('Received Opcode 10 Hello with heartbeat interval %s', self._interval) + await self._task_group.spawn(self._heartbeat_task) + await self._identify_or_resume() + + async def _on_heartbeat_ack(self, _): + logger.debug('Received Opcode 11 Heartbeat ACK') + self._last_ack = ack_time = perf_counter() + self.latency = ack_time - self._last_sent + self._heartbeat_acknowledged = True + + async def _process_message(self, message: Union[bytes, str]): + logger.debug('Received %s', message) + + # Decompress zlib stream if given. + if self.zlib_compressed: + self.buffer.extend(message) + + # The WebSocket protocol supports entire payloads being + # split across multiple messages. Though there are no + # known cases where Discord does that, it is still + # possible and we handle that for convenience. + if len(message) < 4 or message[-4:] != self.ZLIB_SUFFIX: + return + + message = self.inflator.decompress(self.buffer).decode('utf-8') + self.buffer = bytearray() + elif message[0] not in ('{', 131): # Neither JSON nor ETF. + # In special cases, the gateway may also send + # zlib-compressed payloads even without the + # compress URL parameter being set. + message = zlib.decompress(message, 15).decode('utf-8') + + # Decode ETF/JSON payload. + try: + message = self.encoder.decode(message) + except Exception as e: + raise EncodingFailed(str(e)) + + if 's' in message: + self.sequence = message['s'] + + opcode = Opcode(message['op']) + event_data = message['d'] + + if opcode is Opcode.DISPATCH: + await self.emitter.emit(opcode, message['t'].upper(), event_data) + else: + await self.emitter.emit(opcode, event_data) + + async def _identify_or_resume(self): + if self._session_id and self.sequence: + # Since session_id and sequence are set, + # we attempt to resume the connection. + logger.debug('Attempting to resume connection with Session ID %s and Sequence %s', + self._session_id, self.sequence) + await self._send(Opcode.RESUME, resume(self._token, self._session_id, self.sequence)) + else: + logger.debug('Performing identify handshake') + await self._send( + Opcode.IDENTIFY, + identify(self._token, shard=[self.shard_id, self.shard_count]) + ) + + async def _handle_closure(self, code: GatewayCloseCode, reason: str): + logger.info('Connection was closed with %s (%s): %s', code.value, code.name, reason) + + # Clean up old data + self._con = None + self._running = False + if self.zlib_compressed: + self.buffer = bytearray() + self.inflator = zlib.decompressobj() + + if not self._should_reconnect: + return + + # These codes denote errors. In such cases + # we don't want to resume connections, but + # restart them. + if 4000 <= code.value <= 4011: + self._session_id = None + self._interval = None + + action = 'resume' if self._session_id else 'reconnect' + delay = randint(10, 20) + logger.debug('Attempting to %s after %s seconds', action, delay) + + await anyio.sleep(delay) + await self._connect() + + async def _connect(self): + logger.debug('Opening WebSocket connection to %s', self.url) + + async with anysocks.open_connection(self.url) as con: + self._con = con + self._running = True + + while not self._con.closed and self._running: + try: + message = await self._con.get_message() + except anysocks.exceptions.ConnectionClosed: + break + + await self._process_message(message) + + close_code = GatewayCloseCode(self._con.close_code.value) + close_reason = self._con.close_reason or '' + + await self._handle_closure(close_code, close_reason) + + async def start(self, token: str): + self._token = token + async with anyio.create_task_group() as task_group: + self._task_group = task_group + await self._task_group.spawn(self._connect) + + async def close(self): + self._should_reconnect = False + await self._con.close(1000) diff --git a/clamor/gateway/encoding/__init__.py b/clamor/gateway/encoding/__init__.py new file mode 100644 index 0000000..925ab67 --- /dev/null +++ b/clamor/gateway/encoding/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- + +from .json import JSONEncoder + +ENCODERS = { + 'json': JSONEncoder, +} + +try: + from .etf import ETFEncoder +except ImportError: + pass +else: + ENCODERS['etf'] = ETFEncoder diff --git a/clamor/gateway/encoding/base.py b/clamor/gateway/encoding/base.py new file mode 100644 index 0000000..463b1e7 --- /dev/null +++ b/clamor/gateway/encoding/base.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- + +import abc + + +class BaseEncoder(abc.ABC): + """An Abstract Base Class for implementing encoders to communicate with the Discord Gateway.""" + + TYPE = None + OPCODE = None + + @staticmethod + @abc.abstractmethod + def decode(data): + pass + + @staticmethod + @abc.abstractmethod + def encode(data): + pass diff --git a/clamor/gateway/encoding/etf.py b/clamor/gateway/encoding/etf.py new file mode 100644 index 0000000..26922b6 --- /dev/null +++ b/clamor/gateway/encoding/etf.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- + +import earl +from wsproto.frame_protocol import Opcode + +from .base import BaseEncoder + +__all__ = ( + 'ETFEncoder', +) + + +class ETFEncoder(BaseEncoder): + """An encoder that will be used to handle received Gateway payloads. + + This will be used when communication should be done in Erlang's ETF format. + """ + + TYPE = 'etf' + OPCODE = Opcode.BINARY + + @staticmethod + def decode(data): + return earl.unpack(data, encoding='utf-8', encode_binary_ext=True) + + @staticmethod + def encode(data): + return earl.pack(data) diff --git a/clamor/gateway/encoding/json.py b/clamor/gateway/encoding/json.py new file mode 100644 index 0000000..bd44051 --- /dev/null +++ b/clamor/gateway/encoding/json.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- + +try: + import ujson as json +except ImportError: + import json +from wsproto.frame_protocol import Opcode + +from .base import BaseEncoder + +__all__ = ( + 'JSONEncoder', +) + + +class JSONEncoder(BaseEncoder): + """An encoder that will be used to handle received Gateway payloads. + + This will be used when communication should be done in JSON format. + """ + + TYPE = 'json' + OPCODE = Opcode.TEXT + + @staticmethod + def decode(data): + return json.loads(data) + + @staticmethod + def encode(data): + return json.dumps(data) diff --git a/clamor/gateway/opcodes.py b/clamor/gateway/opcodes.py new file mode 100644 index 0000000..bb75d9f --- /dev/null +++ b/clamor/gateway/opcodes.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- + +from enum import IntEnum + +__all__ = ( + 'Opcode', +) + + +class Opcode(IntEnum): + """Gateway opcodes.""" + + #: Dispatches an event. + DISPATCH = 0 + #: Used for ping checking. + HEARTBEAT = 1 + #: Used for client handshake. + IDENTIFY = 2 + #: Used to update the client status. + STATUS_UPDATE = 3 + #: Used to join/move/leave voice channels. + VOICE_STATE_UPDATE = 4 + #: Used to check ping time to a voice channel. + VOICE_PING = 5 + #: Used to resume a closed connection. + RESUME = 6 + #: Used to tell clients to reconnect to the gateway. + RECONNECT = 7 + #: Used to request guild members. + REQUEST_GUILD_MEMBERS = 8 + #: Used to notify the client that they have an invalid session id. + INVALID_SESSION = 9 + #: Sent immediately after connecting, contains heartbeat and server debug information. + HELLO = 10 + #: Sent immediately following a client heartbeat that was received. + HEARTBEAT_ACK = 11 + #: Used to request a client sync. + GUILD_SYNC = 12 diff --git a/clamor/gateway/serialization.py b/clamor/gateway/serialization.py new file mode 100644 index 0000000..8d53399 --- /dev/null +++ b/clamor/gateway/serialization.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- + +import platform +from typing import List + +from ..meta import __title__ as name + +__all__ = ( + 'identify', + 'resume', +) + + +def identify(token: str, compress: bool = True, large_threshold: int = 50, + *, shard: List[int] = None, presence: dict = None, + guild_subscriptions: bool = True) -> dict: + """Returns an identify payload to complete the client handshake.""" + + presence = presence or {'since': None, 'game': None, 'status': 'online', 'afk': False} + shard = shard or [0, 1] + + return { + 'token': token, + 'properties': { + '$os': platform.system(), + '$browser': name, + '$device': name + }, + 'compress': compress, + 'large_threshold': large_threshold, + 'shard': shard, + 'presence': presence, + 'guild_subscriptions': guild_subscriptions + } + + +def resume(token: str, session_id: str, seq: int) -> dict: + """Returns a resume payload to continue a closed connection.""" + + return { + 'token': token, + 'session_id': session_id, + 'seq': seq + } diff --git a/clamor/models/__init__.py b/clamor/models/__init__.py new file mode 100644 index 0000000..ed83da3 --- /dev/null +++ b/clamor/models/__init__.py @@ -0,0 +1,10 @@ +from .audit_log import AuditLog, AuditLogAction +from .channel import Channel +from .emoji import Emoji +from .guild import Base, Integration, Role, Guild, GuildEmbed +from .invite import Invite +from .message import Message +from .snowflake import Snowflake +from .user import Connection, PremiumType, User, UserFlags +from .voice import VoiceRegion +from .webhook import Webhook diff --git a/clamor/rest/endpoints/audit_log.py b/clamor/models/audit_log.py similarity index 54% rename from clamor/rest/endpoints/audit_log.py rename to clamor/models/audit_log.py index d2af132..f063554 100644 --- a/clamor/rest/endpoints/audit_log.py +++ b/clamor/models/audit_log.py @@ -1,15 +1,6 @@ -# -*- coding: utf-8 -*- - from enum import IntEnum -from typing import Union - -from ..routes import Routes -from .base import * -__all__ = ( - 'AuditLogAction', - 'AuditLogWrapper', -) +from .base import Base class AuditLogAction(IntEnum): @@ -79,29 +70,5 @@ class AuditLogAction(IntEnum): MESSAGE_DELETE = 72 -class AuditLogWrapper(EndpointsWrapper): - """A higher-level wrapper around Audit Log endpoints. - - .. seealso:: Audit Log endpoints https://discordapp.com/developers/docs/resources/audit-log - """ - - def __init__(self, token: str, guild_id: Snowflake): - super().__init__(token) - - self.guild_id = guild_id - - async def get_guild_audit_log(self, - user_id: Snowflake, - action_type: Union[AuditLogAction, int] = None, - before: Snowflake = None, - limit: int = 50) -> dict: - params = optional(**{ - 'user_id': user_id, - 'action_type': action_type if isinstance(action_type, int) else action_type.value, - 'before': before, - 'limit': limit, - }) - - return await self.http.make_request(Routes.GET_GUILD_AUDIT_LOG, - dict(guild=self.guild_id), - params=params) +class AuditLog(Base): + pass diff --git a/clamor/models/base.py b/clamor/models/base.py new file mode 100644 index 0000000..6539c3c --- /dev/null +++ b/clamor/models/base.py @@ -0,0 +1,66 @@ +from enum import Enum +from typing import Any, Callable, Dict, Type, Tuple, Union + +from .snowflake import Snowflake + + +class Field: + + def __init__(self, typ: Callable, alt: str = None): + self.type = typ + self.alt = alt + self._name = None + + def set_name(self, name: str): + if self._name is not None: + raise AttributeError("Name was already set, can not be set again.") + self._name = name + + def __call__(self, value: Any): + if value is None: + return + + if not isinstance(value, self.type): + return self.type(value) + return value + + +class BaseMeta(type): + + def __new__(mcs, name: str, bases: Tuple[Type[Any]], clsattrs: Dict[str, Any]): + for name, field in clsattrs.items(): + if isinstance(field, Field): + field.set_name(name) + return super().__new__(mcs, name, bases, clsattrs) + + +class Base(metaclass=BaseMeta): + + def __init__(self, source: Dict[str, Any], client): + self.name_ = self.__class__.__name__ + self.source = source + self.client = client + if self.id is not None: + self.client.cache.active[self.__class__.__name__][self.id] = self + + def __getattr__(self, item: str): + value = super().__getattribute__(item) + if isinstance(value, Field): + return value(self.source[value.alt or item]) + return value + + +class Flags(Enum): + + @classmethod + def get(cls, flags: int): + return tuple(a for a in cls if flags & a.value) + + +Snowflakable = Union[str, int, Base] + + +def snowflakify(obj: Snowflakable): + if isinstance(obj, Base): + return obj.id + return Snowflake(obj) diff --git a/clamor/models/channel.py b/clamor/models/channel.py new file mode 100644 index 0000000..c812bae --- /dev/null +++ b/clamor/models/channel.py @@ -0,0 +1,5 @@ +from .base import Base + + +class Channel(Base): + pass diff --git a/clamor/models/emoji.py b/clamor/models/emoji.py new file mode 100644 index 0000000..36c2b8b --- /dev/null +++ b/clamor/models/emoji.py @@ -0,0 +1,5 @@ +from .base import Base + + +class Emoji(Base): + pass diff --git a/clamor/models/guild.py b/clamor/models/guild.py new file mode 100644 index 0000000..b0a684e --- /dev/null +++ b/clamor/models/guild.py @@ -0,0 +1,25 @@ +from .base import Base + + +class Ban(Base): + pass + + +class Integration(Base): + pass + + +class Role(Base): + pass + + +class Member(Base): + pass + + +class Guild(Base): + pass + + +class GuildEmbed(Base): + pass diff --git a/clamor/models/invite.py b/clamor/models/invite.py new file mode 100644 index 0000000..8000a36 --- /dev/null +++ b/clamor/models/invite.py @@ -0,0 +1,5 @@ +from .base import Base + + +class Invite(Base): + pass diff --git a/clamor/models/message.py b/clamor/models/message.py new file mode 100644 index 0000000..f6f9fff --- /dev/null +++ b/clamor/models/message.py @@ -0,0 +1,5 @@ +from .base import Base + + +class Message(Base): + pass diff --git a/clamor/models/snowflake.py b/clamor/models/snowflake.py new file mode 100644 index 0000000..10a4927 --- /dev/null +++ b/clamor/models/snowflake.py @@ -0,0 +1,66 @@ +from datetime import datetime + + +class Snowflake(int): + + DISCORD_EPOCH = 1420070400000 + + """ + An object to represent a discord snowflake + + Keep in mind, that this is just a normal int with some added properties. This is the only object that doesn't + inherit from :py:class:`~pycord.models.base.Model`. For the most part, you can just treat this like a normal int. + + Attributes + ---------- + increment : int + "For every ID that is generated on that process, this number is incremented" ~ discord docs + internal_process_id : int + Undocumented, but supposedly the ID of the process that made the snowflake + internal_worker_id : int + Undocumented, but supposedly the ID of the worker that made the snowflake + timestamp : datetime.datetime + A datetime object containing the point in time that the snowflake was created + """ + + @property + def increment(self): + return self & 0xFFF + + @property + def internal_process_id(self): + return (self & 0x1F000) >> 12 + + @property + def internal_worker_id(self): + return (self & 0x3E0000) >> 17 + + @property + def timestamp(self): + return datetime.utcfromtimestamp(((self >> 22) + self.DISCORD_EPOCH) / 1000) + + def is_valid(self): + """ + Complete a series of checks to see if it could be a snowflake + + The following checks are to ensure that it *could* be a discord ID. + 1. Makes sure it isn't 0 or less + 2. It makes sure the snowflake isn't larger than 64 bits. + 3. It makes sure the snowflake is at least 22 bits. + 4. It makes sure the timestamp isn't less than the discord epoch. + 5. It makes sure the timestamp isn't greater than now + + Returns + ------- + bool + True if the ID could belong to discord, False otherwise. + """ + if self <= 0: + return False + elif 22 > self.bit_length() > 64: + return False + elif self.timestamp < datetime.utcfromtimestamp(self.DISCORD_EPOCH / 1000): + return False + elif self.timestamp > datetime.now(): + return False + return True diff --git a/clamor/models/user.py b/clamor/models/user.py new file mode 100644 index 0000000..07b7d72 --- /dev/null +++ b/clamor/models/user.py @@ -0,0 +1,172 @@ +from enum import Enum + +from .base import Base, Field, Flags +from .snowflake import Snowflake + +DEFAULT_AVATAR_URL = 'https://cdn.discordapp.com/embed/avatars/{}.png' +AVATAR_URL = 'https://cdn.discordapp.com/avatars/{}/{}.{}?size={}' + + +class UserFlags(Flags): + + DISCORD_EMPLOYEE = 1 + DISCORD_PARTNER = 2 + HYPESQUAD_EVENTS = 4 + BUG_HUNTER = 8 + HOUSE_BRAVERY = 16 + HOUSE_BRILLIANCE = 32 + HOUSE_BALANCE = 64 + EARLY_SUPPORTER = 128 + TEAM_USER = 256 + + +class PremiumType(Enum): + + DEFAULT = 0 + NITRO_CLASSIC = 1 + NITRO = 2 + + +class User(Base): + """ + A discord user model + + This model represents normal discord users, not users from Oauth. While you can't send messages + to users directly, you can create a channel with them by using the `dm` method. + + Attributes + ---------- + id : :class:`clamor.models.snowflake.Snowflake` + The user's discord ID + username : str + The user's name without the discriminator + discriminator : str + A number that separates people with the same username + avatar : str + The user's profile picture's hash + bot : bool + If True, the user is a bot account + mfa_enabled : bool + If True, the user uses 2factor authentication + locale : str + The user's preferred language + flags : :class:`clamor.models.user.UserFlags` + Certain user attributes that are displayed on the user's profile as icons + premium_type : :class:`clamor.model.user.PremiumType` + Information on the user's paid subscription status + avatar_url : str + Shorthand for :func:`clamor.model.user.User.create_avatar_url` without arguments + creation_date : datetime.datetime + When the user registered with discord (shorthand for user.id.timestamp) + mention : str + A string containing the user as a mention + name : str + The username and discriminator combined with a '#' + """ + + id = Field(Snowflake) # type: Snowflake + username = Field(str) # type: str + discriminator = Field(str) # type: str + avatar = Field(str) # type: str + bot = Field(bool) # type: bool + mfa_enabled = Field(bool) # type: bool + locale = Field(str) # type: str + flags = Field(UserFlags.get) # type: Tuple[UserFlags] + premium_type = Field(PremiumType) # type: PremiumType + + @property + def avatar_url(self): + return self.create_avatar_url() + + @property + def creation_date(self): + return self.id.timestamp + + @property + def mention(self): + return "<@{}>".format(self.id) + + @property + def name(self): + return "{}#{}".format(self.username, self.discriminator) + + def create_avatar_url(self, format: str = None, size: int = 1024): + """ + Create a url to get the user's avatar + + This function will create a link to the user's avatar. If they have not set on it will link + to a default avatar. You can specify the format, but if you don't, webp will be used for + normal avatars and gif for animated ones. You can also specify the size. + + Parameters + ---------- + format : str + The image format, which can be jpeg / jpg, png, webp, or gif + size : int + The size of the image, can be any power of 2 from 16 to 2048 + + Returns + ------- + str + A url to the user's avatar + """ + if not self.avatar: + return DEFAULT_AVATAR_URL.format(int(self.discriminator) % 5) + if format is not None: + return AVATAR_URL.format(self.id, self.avatar, format, size) + if self.avatar.startswith("a_"): + return AVATAR_URL.format(self.id, self.avatar, "gif", size) + return AVATAR_URL.format(self.id, self.avatar, "webp", size) + + def dm(self): + """ + Create direct message + + Create a direct message with a user, and return a new channel to represent the conversation. + + Returns + ------- + :class:`clamor.models.channel.Channel` + A new DM channel object + """ + return self.client.api.create_dm(self.id) + + +class Visibility(Enum): + INVISIBLE = 0 + VISIBLE = 1 + + +class Connection(Base): + """ + User connections + + Other accounts the user controls, such as youtube, twitch, spotify, etc. + + Attributes + ---------- + id : str + The ID of the account + name : str + The name of the account + type : str + The name of the service (youtube, twitch, etc) + revoked : bool + Whether the connect if revoked + verified : bool + Whether the user proved it's their account + friend_sync : bool + Whether friend sync is enabled + show_activity : bool + Whether changes on this activity will appear on presence updates + visibility : :class:`clamor.models.user.Visibility` + Whether only this user can view the connection + """ + id = Field(str) + name = Field(str) + type = Field(str) + revoked = Field(bool) + verified = Field(bool) + friend_sync = Field(bool) + show_activity = Field(bool) + visibility = Field(Visibility) diff --git a/clamor/models/voice.py b/clamor/models/voice.py new file mode 100644 index 0000000..dc48531 --- /dev/null +++ b/clamor/models/voice.py @@ -0,0 +1,5 @@ +from .base import Base + + +class VoiceRegion(Base): + pass diff --git a/clamor/models/webhook.py b/clamor/models/webhook.py new file mode 100644 index 0000000..1043f1e --- /dev/null +++ b/clamor/models/webhook.py @@ -0,0 +1,148 @@ +from clamor.utils.files import File +from .base import Base, Field, Snowflakable, snowflakify +from .snowflake import Snowflake +from .user import User, AVATAR_URL + + +class Webhook(Base): + """ + A model to represent discord's webhooks + + Webhooks are easy mode bots, as discord puts it. This model allows you to examine and iteract + with them. Some methods allow you to use the webhook's token, instead of needing permissions. + By default, it won't use the token, because you usually won't have access to it. + + Attributes + ---------- + id : Snowflake + The ID of the webhook + guild_id : Snowflake + The guild's ID, that the webhook belongs too + channel_id : Snowflake + The channel'd ID, that the webhook posts in + user : :class:`clamor.models.user.User` + The user that created this webhook + name : str + The name of the webhook + avatar : str + The webhook avatar hash + token : str + A secure token that controls the webhook + guild : :class:`clamor.models.guild.Guild` + The guild the webhook belongs too + channel : :class:`clamor.models.channel.Channel` + The channel the webhook posts in + avatar_url : str + Shorthand for create_avatar_url with no parameters + """ + id = Field(Snowflake) # type: Snowflake + guild_id = Field(Snowflake) # type: Snowflake + channel_id = Field(Snowflake) # type: Snowflake + user = Field(User) # type: User + name = Field(str) # type: str + avatar = Field(str) # type: str + token = Field(str) # type: str + + @property + def guild(self): + return self.client.cache.get("Guild", self.guild_id) + + @property + def channel(self): + return self.client.cache.get("Channel", self.channel_id) + + @property + def avatar_url(self): + return self.create_avatar_url() + + def create_avatar_url(self, format: str = None, size: int = 1024) -> str: + """ + Create a url to get the webhook's avatar + + This function will create a link to the webhook's avatar. If they have not set on it will + link to a default avatar. You can specify the format, but if you don't, webp will be used + for normal avatars and gif for animated ones. You can also specify the size. + + Parameters + ---------- + format : str + The image format, which can be jpeg / jpg, png, webp, or gif + size : int + The size of the image, can be any power of 2 from 16 to 2048 + + Returns + ------- + str + A url to the webhook's avatar + """ + if format is not None: + return AVATAR_URL.format(self.id, self.avatar, format, size) + if self.avatar.startswith("a_"): + return AVATAR_URL.format(self.id, self.avatar, "gif", size) + return AVATAR_URL.format(self.id, self.avatar, "webp", size) + + def modify(self, name: str = None, avatar: File = None, channel: Snowflakable = None, + use_token: bool = False, reason: str = None) -> 'Webhook': + """ + Modify the name, avatar, and or channel + + Modify the name, avatar, and or channel which the webhook posts in. If none of those + parameters are provided, it will just return itself. If you use a token to call this + function, you cannot change what channel the webhook posts in, so that parameter will be + ignored. On success, it will return the modified webhook. This webhook won't change. + + Parameters + ---------- + name : str + The new name of the webhook + avatar : :class:`clamor.utils.files.File` + The new avatar of the webhook + channel : Snowflakable + The new channel the webhook will post in (cannot change if using token) + use_token : bool + Instead of using permissions, use the token instead + reason : str + The reason for the modification + + Returns + ------- + :class:`clamor.models.webhook.Webhook` + The new, modified webhook + """ + if not any((name, avatar, channel)): + return self + + if use_token: + return self.client.api.modify_webhook_with_token( + self.id, + self.token, + name=name, + avatar=avatar.data_uri, + reason=reason + ) + else: + return self.client.api.modify_webhook( + self.id, + name=name, + avatar=avatar.data_uri, + channel_id=snowflakify(channel), + reason=reason + ) + + def delete(self, use_token: bool = False, reason: str = None): + """ + Delete the webhook + + Parameters + ---------- + use_token : bool + Instead of using permissions, use the token instead + reason: str + The reason for deleting the webhook + """ + if use_token: + self.client.api.delete_webhook_with_token(self.id, self.token, reason) + else: + self.client.api.delete_webhook(self.id, reason) + + # TODO: Added method to execute the webhook, I guess diff --git a/clamor/rest/__init__.py b/clamor/rest/__init__.py index b4f9963..f3f78bf 100644 --- a/clamor/rest/__init__.py +++ b/clamor/rest/__init__.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- -from . import endpoints from .http import * from .rate_limit import * from .routes import * diff --git a/clamor/rest/api.py b/clamor/rest/api.py new file mode 100644 index 0000000..6ebd6b9 --- /dev/null +++ b/clamor/rest/api.py @@ -0,0 +1,1016 @@ +import json +from functools import wraps +from typing import Union, Type, List, Optional + +from clamor.models.audit_log import AuditLog, AuditLogAction +from clamor.models.base import Base +from clamor.models.channel import Channel +from clamor.models.emoji import Emoji +from clamor.models.guild import Ban, Integration, Guild, GuildEmbed, Member, Role +from clamor.models.invite import Invite +from clamor.models.message import Message +from clamor.models.snowflake import Snowflake +from clamor.models.user import Connection, User +from clamor.models.voice import VoiceRegion +from clamor.models.webhook import Webhook +from clamor.utils.parse import parse_emoji, check_username, check_webhook_name +from .http import HTTP +from .routes import Routes + + +def optional(**kwargs) -> dict: + """Given a dictionary, this filters out all values that are ``None``. + + Useful for routes where certain parameters are optional. + """ + + return { + key: value for key, value in kwargs.items() + if value is not None + } + + +def cast_to(model: Type[Base]): + def func_wrap(func): + @wraps(func) + async def wrapper(self, *args, **kwargs): + result = await func(self, *args, **kwargs) + if isinstance(result, list): + return [model(r, self.client) for r in result] + if isinstance(result, dict): + return model(result, self.client) + return wrapper + return func_wrap + + +class ClamorAPI: + + def __init__(self, client, **kwargs): + self.client = client + self._http = kwargs + + @property + def http(self) -> HTTP: + if isinstance(self._http, dict) and self.client.token is None: + raise AttributeError("Token has not been provided yet") + elif isinstance(self._http, dict): + self._http = HTTP(self.client.token, **self._http) + return self._http + + @cast_to(AuditLog) + async def get_guild_audit_log(self, + guild_id: Snowflake, + user_id: Snowflake, + action_type: Union[AuditLogAction, int] = None, + before: Snowflake = None, + limit: int = 50) -> AuditLog: + params = optional(**{ + 'user_id': user_id, + 'action_type': action_type if isinstance(action_type, int) else action_type.value, + 'before': before, + 'limit': limit, + }) + + return await self.http.make_request(Routes.GET_GUILD_AUDIT_LOG, + dict(guild=guild_id), + params=params) + + @cast_to(Channel) + async def get_channel(self, channel_id: Snowflake) -> Channel: + return await self.http.make_request(Routes.GET_CHANNEL, dict(channel=channel_id)) + + @cast_to(Channel) + async def modify_channel(self, + channel_id: Snowflake, + name: str = None, + position: int = None, + topic: str = None, + nsfw: bool = None, + rate_limit_per_user: int = None, + bitrate: int = None, + user_limit: int = None, + permission_overwrites: list = None, + parent_id: Snowflake = None, + reason: str = None) -> Channel: + + params = optional(**{ + 'name': name, + 'position': position, + 'topic': topic, + 'nsfw': nsfw, + 'rate_limit_per_user': rate_limit_per_user, + 'bitrate': bitrate, + 'user_limit': user_limit, + 'permission_overwrites': permission_overwrites, + 'parent_id': parent_id + }) + + return await self.http.make_request(Routes.MODIFY_CHANNEL, + dict(channel=channel_id), + json=params, + reason=reason) + + @cast_to(Channel) + async def delete_channel(self, channel_id: Snowflake, reason: str = None) -> Channel: + return await self.http.make_request(Routes.DELETE_CHANNEL, + dict(channel=channel_id), + reason=reason) + + @cast_to(Message) + async def get_channel_messages(self, + channel_id: Snowflake, + around: Snowflake = None, + before: Snowflake = None, + after: Snowflake = None, + limit: int = 50) -> List[Message]: + params = optional(**{ + 'around': around, + 'before': before, + 'after': after, + 'limit': limit + }) + + return await self.http.make_request(Routes.GET_CHANNEL_MESSAGES, + dict(channel=channel_id), + params=params) + + @cast_to(Message) + async def get_channel_message(self, channel_id: Snowflake, message_id: Snowflake) -> Message: + return await self.http.make_request(Routes.GET_CHANNEL_MESSAGE, + dict(channel=channel_id, message=message_id)) + + @cast_to(Message) + async def create_message(self, + channel_id: Snowflake, + content: str = None, + nonce: Snowflake = None, + tts: bool = False, + files: list = None, + embed: dict = None) -> Message: + payload = optional(**{ + 'content': content, + 'nonce': nonce, + 'tts': tts, + 'embed': embed + }) + + if files: + if len(files) == 1: + attachments = { + 'file': tuple(files[0]), + } + else: + attachments = { + 'file{}'.format(index): tuple(file) for index, file in enumerate(files) + } + + return await self.http.make_request(Routes.CREATE_MESSAGE, + dict(channel=channel_id), + files=attachments, + data={'payload_json': json.dumps(payload)}) + + return await self.http.make_request(Routes.CREATE_MESSAGE, + dict(channel=channel_id), + json=payload) + + async def create_reaction(self, channel_id: Snowflake, message_id: Snowflake, emoji: str): + return await self.http.make_request(Routes.CREATE_REACTION, + dict(channel=channel_id, + message=message_id, + emoji=parse_emoji(emoji))) + + async def delete_own_reaction(self, channel_id: Snowflake, message_id: Snowflake, emoji: str): + return await self.http.make_request(Routes.DELETE_OWN_REACTION, + dict(channel=channel_id, + message=message_id, + emoji=parse_emoji(emoji))) + + async def delete_user_reaction(self, + channel_id: Snowflake, + message_id: Snowflake, + user_id: Snowflake, + emoji: str): + return await self.http.make_request(Routes.DELETE_USER_REACTION, + dict(channel=channel_id, + message=message_id, + emoji=parse_emoji(emoji), + user=user_id)) + + @cast_to(User) + async def get_reactions(self, + channel_id: Snowflake, + message_id: Snowflake, + emoji: str, + before: Snowflake = None, + after: Snowflake = None, + limit: int = 25) -> dict: + params = optional(**{ + 'before': before, + 'after': after, + 'limit': limit + }) + + return await self.http.make_request(Routes.GET_REACTIONS, + dict(channel=channel_id, + message=message_id, + emoji=parse_emoji(emoji)), + params=params) + + async def delete_all_reactions(self, channel_id: Snowflake, message_id: Snowflake): + return await self.http.make_request(Routes.DELETE_ALL_REACTIONS, + dict(channel=channel_id, message=message_id)) + + @cast_to(Message) + async def edit_message(self, + channel_id: Snowflake, + message_id: Snowflake, + content: str = None, + embed: dict = None) -> Message: + params = optional(**{ + 'content': content, + 'embed': embed, + }) + + return await self.http.make_request(Routes.EDIT_MESSAGE, + dict(channel=channel_id, message=message_id), + json=params) + + async def delete_message(self, channel_id: Snowflake, + message_id: Snowflake, + reason: str = None): + return await self.http.make_request(Routes.DELETE_MESSAGE, + dict(channel=channel_id, message=message_id), + reason=reason) + + async def bulk_delete_messages(self, channel_id: Snowflake, + messages: List[Snowflake], + reason: str = None): + if 2 <= len(messages) <= 100: + raise ValueError('Bulk delete requires a message count between 2 and 100') + + return await self.http.make_request(Routes.BULK_DELETE_MESSAGES, + dict(channel=channel_id), + json={'messages': messages}, + reason=reason) + + async def edit_channel_permissions(self, + channel_id: Snowflake, + overwrite_id: Snowflake, + allow: int = None, + deny: int = None, + type: str = None, + reason: str = None): + params = optional(**{ + 'allow': allow, + 'deny': deny, + 'type': type + }) + + if params.get('type', 'member') not in ('member', 'role'): + raise ValueError('Argument for type must be either "member" or "role"') + + return await self.http.make_request(Routes.EDIT_CHANNEL_PERMISSIONS, + dict(channel=channel_id, overwrite=overwrite_id), + json=params, + reason=reason) + + @cast_to(Invite) + async def get_channel_invites(self, channel_id: Snowflake) -> List[Snowflake]: + return await self.http.make_request(Routes.GET_CHANNEL_INVITES, + dict(channel=channel_id)) + + @cast_to(Invite) + async def create_channel_invite(self, + channel_id: Snowflake, + max_age: int = 86400, + max_uses: int = 0, + temporary: bool = False, + unique: bool = False, + reason: str = None) -> dict: + params = optional(**{ + 'max_age': max_age, + 'max_uses': max_uses, + 'temporary': temporary, + 'unique': unique + }) + + return await self.http.make_request(Routes.CREATE_CHANNEL_INVITE, + dict(channel=channel_id), + json=params, + reason=reason) + + async def delete_channel_permission(self, + channel_id: Snowflake, + overwrite_id: Snowflake, + reason: str = None): + return await self.http.make_request(Routes.DELETE_CHANNEL_PERMISSION, + dict(channel=channel_id, overwrite=overwrite_id), + reason=reason) + + async def trigger_typing_indicator(self, channel_id: Snowflake): + return await self.http.make_request(Routes.TRIGGER_TYPING_INDICATOR, + dict(channel=channel_id)) + + @cast_to(Message) + async def get_pinned_messages(self, channel_id: Snowflake) -> Message: + return await self.http.make_request(Routes.GET_PINNED_MESSAGES, + dict(channel=channel_id)) + + async def add_pinned_channel_message(self, channel_id: Snowflake, message_id: Snowflake): + return await self.http.make_request(Routes.ADD_PINNED_CHANNEL_MESSAGE, + dict(channel=channel_id, message=message_id)) + + async def delete_pinned_channel_message(self, + channel_id: Snowflake, + message_id: Snowflake, + reason: str = None): + return await self.http.make_request(Routes.DELETE_PINNED_CHANNEL_MESSAGE, + dict(channel=channel_id, message=message_id), + reason=reason) + + async def group_dm_add_recipient(self, + channel_id: Snowflake, + user_id: Snowflake, + access_token: str = None, + nick: str = None): + params = optional(**{ + 'access_token': access_token, + 'nick': nick + }) + + return await self.http.make_request(Routes.GROUP_DM_ADD_RECIPIENT, + dict(channel=channel_id, user=user_id), + json=params) + + async def group_dm_remove_recipient(self, channel_id: Snowflake, user_id: Snowflake): + return await self.http.make_request(Routes.GROUP_DM_REMOVE_RECIPIENT, + dict(channel=channel_id, user=user_id)) + + @cast_to(Emoji) + async def list_guild_emojis(self, guild_id: Snowflake) -> List[Emoji]: + return await self.http.make_request(Routes.LIST_GUILD_EMOJIS, + dict(guild=guild_id)) + + @cast_to(Emoji) + async def get_guild_emoji(self, guild_id: Snowflake, emoji_id: Snowflake) -> Emoji: + return await self.http.make_request(Routes.GET_GUILD_EMOJI, + dict(guild=guild_id, emoji=emoji_id)) + + @cast_to(Emoji) + async def create_guild_emoji(self, + guild_id: Snowflake, + name: str, + image: str, + roles: list, + reason: str = None) -> Emoji: + params = { + 'name': name, + 'image': image, + 'roles': roles + } + + return await self.http.make_request(Routes.CREATE_GUILD_EMOJI, + dict(guild=guild_id), + json=params, + reason=reason) + + @cast_to(Emoji) + async def modify_guild_emoji(self, + guild_id: Snowflake, + emoji_id: Snowflake, + name: str = None, + roles: list = None, + reason: str = None) -> Emoji: + params = optional(**{ + 'name': name, + 'roles': roles + }) + + return await self.http.make_request(Routes.MODIFY_GUILD_EMOJI, + dict(guild=guild_id, emoji=emoji_id), + json=params, + reason=reason) + + async def delete_guild_emoji(self, + guild_id: Snowflake, + emoji_id: Snowflake, + reason: str = None): + return await self.http.make_request(Routes.DELETE_GUILD_EMOJI, + dict(guild=guild_id, emoji=emoji_id), + reason=reason) + + async def get_gateway(self) -> dict: + return await self.http.make_request(Routes.GET_GATEWAY) + + async def get_gateway_bot(self) -> dict: + return await self.http.make_request(Routes.GET_GATEWAY_BOT) + + @cast_to(Guild) + async def create_guild(self, + name: str, + region: str, + icon: str, + verification_level: int, + default_message_notifications: int, + explicit_content_filter: int, + roles: list, + channels: list) -> Guild: + params = { + "name": name, + "region": region, + "icon": icon, + "verification_level": verification_level, + "default_message_notifications": default_message_notifications, + "explicit_content_filter": explicit_content_filter, + "roles": roles, + "channels": channels + } + + return await self.http.make_request(Routes.CREATE_GUILD, + json=params) + + @cast_to(Guild) + async def get_guild(self, guild_id: Snowflake) -> Guild: + return await self.http.make_request(Routes.GET_GUILD, + dict(guild=guild_id)) + + @cast_to(Guild) + async def modify_guild(self, + guild_id: Snowflake, + name: str = None, + region: str = None, + verification_level: int = None, + default_message_notifications: int = None, + explicit_content_filter: int = None, + afk_channel_id: Snowflake = None, + afk_timout: int = None, + icon: str = None, + owner_id: Snowflake = None, + splash: str = None, + system_channel_id: Snowflake = None, + reason: str = None) -> Guild: + params = optional(**{ + "name": name, + "region": region, + "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_timout, + "icon": icon, + "owner_id": owner_id, + "splash": splash, + "system_channel_id": system_channel_id + }) + + return await self.http.make_request(Routes.MODIFY_GUILD, + dict(guild=guild_id), + json=params, + reason=reason) + + async def delete_guild(self, guild_id: Snowflake): + return await self.http.make_request(Routes.DELETE_GUILD, + dict(guild=guild_id)) + + @cast_to(Channel) + async def get_guild_channels(self, guild_id: Snowflake) -> list: + return await self.http.make_request(Routes.GET_GUILD_CHANNELS, + dict(guild=guild_id)) + + @cast_to(Channel) + async def create_guild_channel(self, + guild_id: Snowflake, + name: str, + channel_type: int = None, + topic: str = None, + bitrate: int = None, + user_limit: int = None, + rate_limit_per_user: int = None, + position: int = None, + permission_overwrites: list = None, + parent_id: Snowflake = None, + reason: str = None) -> dict: + params = optional(**{ + "name": name, + "channel_type": channel_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 + }) + + return await self.http.make_request(Routes.CREATE_GUILD_CHANNEL, + dict(guild=guild_id), + json=params, + reason=reason) + + async def modify_guild_channel_positions(self, guild_id: Snowflake, channels: List[dict]): + return await self.http.make_request(Routes.MODIFY_GUILD_CHANNEL_POSITIONS, + dict(guild=guild_id), + json=channels) + + @cast_to(Member) + async def get_guild_member(self, guild_id: Snowflake, user_id: Snowflake) -> Member: + return await self.http.make_request(Routes.GET_GUILD_MEMBER, + dict(guild=guild_id, member=user_id)) + + @cast_to(Member) + async def list_guild_members(self, + guild_id: Snowflake, + limit: int = None, + after: Snowflake = None) -> List[Member]: + params = optional(**{ + "limit": limit, + "after": after + }) + + return await self.http.make_request(Routes.LIST_GUILD_MEMBERS, + dict(guild=guild_id), + json=params) + + @cast_to(Member) + async def add_guild_member(self, + guild_id: Snowflake, + user_id: Snowflake, + access_token: str, + nick: str = None, + roles: list = None, + mute: bool = None, + deaf: bool = None) -> Member: + params = optional(**{ + "access_token": access_token, + "nick": nick, + "roles": roles, + "mute": mute, + "deaf": deaf + }) + + return await self.http.make_request(Routes.ADD_GUILD_MEMBER, + dict(guild=guild_id, member=user_id), + json=params) + + async def modify_guild_member(self, + guild_id: Snowflake, + user_id: Snowflake, + nick: str = None, + roles: list = None, + mute: bool = None, + deaf: bool = None, + channel_id: Snowflake = None, + reason: str = None): + params = optional(**{ + "nick": nick, + "roles": roles, + "mute": mute, + "deaf": deaf, + "channel_id": channel_id + }) + + return await self.http.make_request(Routes.MODIFY_GUILD_MEMBER, + dict(guild=guild_id, member=user_id), + json=params, + reason=reason) + + async def modify_current_user_nick(self, guild_id: Snowflake, + nick: str, reason: str = None) -> str: + params = { + "nick": nick + } + + resp = await self.http.make_request(Routes.MODIFY_CURRENT_USER_NICK, + dict(guild=guild_id), + json=params, + reason=reason) + return resp['nick'] + + async def add_guild_member_role(self, + guild_id: Snowflake, + user_id: Snowflake, + role_id: Snowflake, + reason: str = None): + return await self.http.make_request(Routes.ADD_GUILD_MEMBER_ROLE, + dict(guild=guild_id, member=user_id, role=role_id), + reason=reason) + + async def remove_guild_member_role(self, + guild_id: Snowflake, + user_id: Snowflake, + role_id: Snowflake, + reason: str = None): + return await self.http.make_request(Routes.REMOVE_GUILD_MEMBER_ROLE, + dict(guild=guild_id, member=user_id, role=role_id), + reason=reason) + + async def remove_guild_member(self, guild_id: Snowflake, + user_id: Snowflake, reason: str = None): + return await self.http.make_request(Routes.REMOVE_GUILD_MEMBER, + dict(guild=guild_id, member=user_id), + reason=reason) + + @cast_to(Ban) + async def get_guild_bans(self, guild_id: Snowflake) -> List[Ban]: + return await self.http.make_request(Routes.GET_GUILD_BANS, + dict(guild=guild_id)) + + @cast_to(Ban) + async def get_guild_ban(self, guild_id: Snowflake, user_id: Snowflake) -> Ban: + return await self.http.make_request(Routes.GET_GUILD_BAN, + dict(guild=guild_id, user=user_id)) + + async def create_guild_ban(self, + guild_id: Snowflake, + user_id: Snowflake, + delete_message_days: int = None, + reason: str = None): + params = optional(**{ + "delete_message_days": delete_message_days, + "reason": reason + }) + + return await self.http.make_request(Routes.CREATE_GUILD_BAN, + dict(guild=guild_id, user=user_id), + json=params) + + async def remove_guild_ban(self, guild_id: Snowflake, user_id: Snowflake, reason: str = None): + return await self.http.make_request(Routes.REMOVE_GUILD_BAN, + dict(guild=guild_id, user=user_id), + reason=reason) + + @cast_to(Role) + async def get_guild_roles(self, guild_id: Snowflake) -> Role: + return await self.http.make_request(Routes.GET_GUILD_ROLES, + dict(guild=guild_id)) + + @cast_to(Role) + async def create_guild_role(self, + guild_id: Snowflake, + name: str = None, + permissions: int = None, + color: int = None, + hoist: bool = None, + mentionable: bool = None, + reason: str = None) -> Role: + params = optional(**{ + "name": name, + "permissions": permissions, + "color": color, + "hoist": hoist, + "mentionable": mentionable + }) + + return await self.http.make_request(Routes.CREATE_GUILD_ROLE, + dict(guild=guild_id), + json=params, + reason=reason) + + @cast_to(Role) + async def modify_guild_role_positions(self, guild_id: Snowflake, + roles: List[dict], reason: str = None) -> List[Role]: + return await self.http.make_request(Routes.MODIFY_GUILD_ROLE_POSITIONS, + dict(guild=guild_id), + json=roles, + reason=reason) + + @cast_to(Role) + async def modify_guild_role(self, + guild_id: Snowflake, + role_id: Snowflake, + name: str = None, + permissions: int = None, + color: int = None, + hoist: bool = None, + mentionable: bool = None, + reason: str = None) -> Role: + params = optional(**{ + "name": name, + "permissions": permissions, + "color": color, + "hoist": hoist, + "mentionable": mentionable + }) + + return await self.http.make_request(Routes.MODIFY_GUILD_ROLE, + dict(guild=guild_id, role=role_id), + json=params, + reason=reason) + + async def delete_guild_role(self, guild_id: Snowflake, role_id: Snowflake, reason: str = None): + return await self.http.make_request(Routes.DELETE_GUILD_ROLE, + dict(guild=guild_id, role=role_id), + reason=reason) + + async def get_guild_prune_count(self, guild_id: Snowflake, days: int = None) -> int: + params = optional(**{"days": days}) + resp = await self.http.make_request(Routes.GET_GUILD_PRUNE_COUNT, + dict(guild=guild_id), + json=params) + return resp['pruned'] + + async def begin_guild_prune(self, + guild_id: Snowflake, + days: int = None, + compute_prune_count: bool = None) -> Optional[int]: + params = { + "days": days, + "compute_prune_count": compute_prune_count + } + + resp = await self.http.make_request(Routes.BEGIN_GUILD_PRUNE, + dict(guild=guild_id), + json=params) + return resp['pruned'] + + @cast_to(VoiceRegion) + async def get_guild_voice_regions(self, guild_id: Snowflake) -> List[VoiceRegion]: + return await self.http.make_request(Routes.GET_GUILD_VOICE_REGIONS, + dict(guild=guild_id)) + + @cast_to(Invite) + async def get_guild_invites(self, guild_id: Snowflake) -> List[Invite]: + return await self.http.make_request(Routes.GET_GUILD_INVITES, + dict(guild=guild_id)) + + @cast_to(Integration) + async def get_guild_integrations(self, guild_id: Snowflake) -> List[Integration]: + return await self.http.make_request(Routes.GET_GUILD_INTEGRATIONS, + dict(guild=guild_id)) + + async def create_guild_integration(self, + guild_id: Snowflake, + int_type: str, + int_id: Snowflake, + reason: str = None): + params = { + "type": int_type, + "id": int_id + } + + return await self.http.make_request(Routes.CREATE_GUILD_INTEGRATION, + dict(guild=guild_id), + json=params, + reason=reason) + + async def modify_guild_integration(self, + guild_id: Snowflake, + integration_id: Snowflake, + expire_behavior: int, + expire_grace_period: int, + enable_emoticons: bool, + reason: str = None): + params = { + "expire_behavior": expire_behavior, + "expire_grace_period": expire_grace_period, + "enable_emoticons": enable_emoticons + } + + return await self.http.make_request(Routes.MODIFY_GUILD_INTEGRATION, + dict(guild=guild_id, integration=integration_id), + json=params, + reason=reason) + + async def delete_guild_integration(self, guild_id: Snowflake, + integration_id: Snowflake, reason: str = None): + return await self.http.make_request(Routes.DELETE_GUILD_INTEGRATION, + dict(guild=guild_id, integration=integration_id), + reason=reason) + + async def sync_guild_integration(self, guild_id: Snowflake, integration_id: Snowflake): + return await self.http.make_request(Routes.SYNC_GUILD_INTEGRATION, + dict(guild=guild_id, integration=integration_id)) + + @cast_to(GuildEmbed) + async def get_guild_embed(self, guild_id: Snowflake) -> GuildEmbed: + return await self.http.make_request(Routes.GET_GUILD_EMBED, + dict(guild=guild_id)) + + @cast_to(GuildEmbed) + async def modify_guild_embed(self, + guild_id: Snowflake, + enabled: bool, + channel_id: Snowflake, + reason: str = None) -> GuildEmbed: + params = { + "enabled": enabled, + "channel_id": channel_id + } + + return await self.http.make_request(Routes.MODIFY_GUILD_EMBED, + dict(guild=guild_id), + json=params, + reason=reason) + + async def get_guild_vanity_url(self, guild_id: Snowflake) -> bytes: + return await self.http.make_request(Routes.GET_GUILD_VANITY_URL, + dict(guild=guild_id)) + + @cast_to(Invite) + async def get_invite(self, invite_code: str, with_counts: bool = False) -> Invite: + return await self.http.make_request(Routes.GET_INVITE, + dict(invite=invite_code), + params=optional(**{'with_counts': with_counts})) + + @cast_to(Invite) + async def delete_invite(self, invite_code: str, reason: str = None) -> Invite: + return await self.http.make_request(Routes.DELETE_INVITE, + dict(invite=invite_code), + reason=reason) + + async def get_current_application_info(self) -> dict: + return await self.http.make_request(Routes.GET_CURRENT_APPLICATION_INFO) + + @cast_to(User) + async def get_current_user(self) -> User: + return await self.http.make_request(Routes.GET_CURRENT_USER) + + @cast_to(User) + async def get_user(self, user_id: Snowflake) -> User: + return await self.http.make_request(Routes.GET_USER, + dict(user=user_id)) + + @cast_to(User) + async def modify_current_user(self, username: str = None, avatar: str = None) -> User: + params = optional(**{ + 'username': check_username(username), + 'avatar': avatar + }) + + return await self.http.make_request(Routes.MODIFY_CURRENT_USER, + json=params) + + @cast_to(Guild) + async def get_current_user_guilds(self, + before: Snowflake = None, + after: Snowflake = None, + limit: int = 100) -> List[Guild]: + params = optional(**{ + 'before': before, + 'after': after, + 'limit': limit + }) + + return await self.http.make_request(Routes.GET_CURRENT_USER_GUILDS, + params=params) + + async def leave_guild(self, guild_id: Snowflake): + return await self.http.make_request(Routes.LEAVE_GUILD, + dict(guild=guild_id)) + + @cast_to(Channel) + async def get_user_dms(self) -> List[Channel]: + return await self.http.make_request(Routes.GET_USER_DMS) + + @cast_to(Channel) + async def create_dm(self, recipient_id: Snowflake) -> Channel: + return await self.http.make_request(Routes.CREATE_DM, + json={'recipient_id': recipient_id}) + + @cast_to(Channel) + async def create_group_dm(self, access_tokens: List[str], nicks: dict) -> Channel: + params = { + 'access_tokens': access_tokens, + 'nicks': nicks, + } + + return await self.http.make_request(Routes.CREATE_GROUP_DM, + json=params) + + @cast_to(Connection) + async def get_user_connections(self) -> List[Connection]: + return await self.http.make_request(Routes.GET_USER_CONNECTIONS) + + @cast_to(VoiceRegion) + async def list_voice_regions(self) -> List[VoiceRegion]: + return await self.http.make_request(Routes.LIST_VOICE_REGIONS) + + @cast_to(Webhook) + async def create_webhook(self, + channel_id: Snowflake, + name: str, + avatar: str = None, + reason: str = None) -> Webhook: + params = { + 'name': check_webhook_name(name), + 'avatar': avatar, + } + + return await self.http.make_request(Routes.CREATE_WEBHOOK, + dict(channel=channel_id), + json=params, + reason=reason) + + @cast_to(Webhook) + async def get_channel_webhooks(self, channel_id: Snowflake) -> List[Webhook]: + return await self.http.make_request(Routes.GET_CHANNEL_WEBHOOKS, + dict(channel=channel_id)) + + @cast_to(Webhook) + async def get_guild_webhooks(self, guild_id: Snowflake) -> List[Webhook]: + return await self.http.make_request(Routes.GET_GUILD_WEBHOOKS, + dict(guild=guild_id)) + + @cast_to(Webhook) + async def get_webhook(self, webhook_id: Snowflake) -> dict: + return await self.http.make_request(Routes.GET_WEBHOOK, + dict(webhook=webhook_id)) + + @cast_to(Webhook) + async def get_webhook_with_token(self, webhook_id: Snowflake, webhook_token: str) -> dict: + return await self.http.make_request(Routes.GET_WEBHOOK_WITH_TOKEN, + dict(webhook=webhook_id, token=webhook_token)) + + @cast_to(Webhook) + async def modify_webhook(self, + webhook_id: Snowflake, + name: str = None, + avatar: str = None, + channel_id: Snowflake = None, + reason: str = None) -> Webhook: + params = optional(**{ + 'name': check_webhook_name(name), + 'avatar': avatar, + 'channel_id': channel_id + }) + + return await self.http.make_request(Routes.MODIFY_WEBHOOK, + dict(webhook=webhook_id), + json=params, + reason=reason) + + @cast_to(Webhook) + async def modify_webhook_with_token(self, + webhook_id: Snowflake, + webhook_token: str, + name: str = None, + avatar: str = None, + reason: str = None) -> dict: + params = optional(**{ + 'name': check_webhook_name(name), + 'avatar': avatar + }) + + return await self.http.make_request(Routes.MODIFY_WEBHOOK_WITH_TOKEN, + dict(webhook=webhook_id, token=webhook_token), + json=params, + reason=reason) + + async def delete_webhook(self, webhook_id: Snowflake, reason: str = None): + return await self.http.make_request(Routes.DELETE_WEBHOOK, + dict(webhook=webhook_id), + reason=reason) + + async def delete_webhook_with_token(self, + webhook_id: Snowflake, + webhook_token: str, + reason: str = None): + return await self.http.make_request(Routes.DELETE_WEBHOOK_WITH_TOKEN, + dict(webhook=webhook_id, token=webhook_token), + reason=reason) + + async def execute_webhook(self, + webhook_id: Snowflake, + webhook_token: str, + content: str = None, + username: str = None, + avatar_url: str = None, + tts: bool = False, + files: list = None, + embeds: list = None, + wait: bool = False): + if not content and not files and not embeds: + raise ValueError('At least one of content, files or embeds is required') + + payload = optional(**{ + 'content': content, + 'username': username, + 'avatar_url': avatar_url, + 'tts': tts, + 'embeds': embeds + }) + + params = optional(**{ + 'wait': wait + }) + + if files: + if len(files) == 1: + attachments = { + 'file': tuple(files[0]), + } + else: + attachments = { + 'file{}'.format(index): tuple(file) for index, file in enumerate(files) + } + + return await self.http.make_request(Routes.EXECUTE_WEBHOOK, + dict(webhook=webhook_id, token=webhook_token), + files=attachments, + data={'payload_json': json.dumps(payload)}, + params=params) + + return await self.http.make_request(Routes.EXECUTE_WEBHOOK, + dict(webhook=webhook_id, token=webhook_token), + json=payload, + params=params) diff --git a/clamor/rest/endpoints/__init__.py b/clamor/rest/endpoints/__init__.py deleted file mode 100644 index f2fea27..0000000 --- a/clamor/rest/endpoints/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# -*- coding: utf-8 -*- - -from . import base -from .audit_log import * -from .channel import * -from .emoji import * -from .gateway import * -from .guild import * -from .invite import * -from .oauth import * -from .user import * -from .voice import * -from .webhook import * diff --git a/clamor/rest/endpoints/base.py b/clamor/rest/endpoints/base.py deleted file mode 100644 index 369aaf8..0000000 --- a/clamor/rest/endpoints/base.py +++ /dev/null @@ -1,56 +0,0 @@ -# -*- coding: utf-8 -*- - -from contextlib import contextmanager -from typing import NewType, Union - -from ..http import HTTP - -__all__ = ( - 'Snowflake', - 'optional', - 'EndpointsWrapper', -) - -#: A type for denoting raw snowflake parameters. -Snowflake = NewType('Snowflake', Union[int, str]) - - -def optional(**kwargs) -> dict: - """Given a dictionary, this filters out all values that are ``None``. - - Useful for routes where certain parameters are optional. - """ - - return { - key: value for key, value in kwargs.items() - if value is not None - } - - -class EndpointsWrapper: - """Base class for higher-level wrappers for API endpoints.""" - - __slots__ = ('http',) - - def __init__(self, token: str): - self.http = HTTP(token) - - @property - def token(self) -> str: - """The token that is used for API authorization.""" - - return self.http.token - - @contextmanager - def raw_responses(self): - """A contextmanager that yields all raw responses this instance holds. - - .. warning:: - - Do not use this if you don't know what you're doing. - """ - - try: - yield self.http.responses - finally: - self.http.responses.clear() diff --git a/clamor/rest/endpoints/channel.py b/clamor/rest/endpoints/channel.py deleted file mode 100644 index 5ca5e90..0000000 --- a/clamor/rest/endpoints/channel.py +++ /dev/null @@ -1,272 +0,0 @@ -# -*- coding: utf-8 -*- - -import json -import re -from typing import List - -from ..routes import Routes -from .base import * - -__all__ = ( - 'ChannelWrapper', -) - - -class ChannelWrapper(EndpointsWrapper): - """A higher-level wrapper around Channel endpoints. - - .. seealso:: Channel endpoints https://discordapp.com/developers/docs/resources/channel - """ - - def __init__(self, token: str, channel_id: Snowflake): - super().__init__(token) - - self.channel_id = channel_id - - @staticmethod - def _parse_emoji(emoji: str) -> str: - match = re.match(r'', emoji) - if match: - emoji = match.group(1) - - return emoji - - async def get_channel(self) -> dict: - return await self.http.make_request(Routes.GET_CHANNEL, dict(channel=self.channel_id)) - - async def modify_channel(self, - name: str = None, - position: int = None, - topic: str = None, - nsfw: bool = None, - rate_limit_per_user: int = None, - bitrate: int = None, - user_limit: int = None, - permission_overwrites: list = None, - parent_id: Snowflake = None, - reason: str = None) -> dict: - params = optional(**{ - 'name': name, - 'position': position, - 'topic': topic, - 'nsfw': nsfw, - 'rate_limit_per_user': rate_limit_per_user, - 'bitrate': bitrate, - 'user_limit': user_limit, - 'permission_overwrites': permission_overwrites, - 'parent_id': parent_id - }) - - return await self.http.make_request(Routes.MODIFY_CHANNEL, - dict(channel=self.channel_id), - json=params, - reason=reason) - - async def delete_channel(self, reason: str = None) -> dict: - return await self.http.make_request(Routes.DELETE_CHANNEL, - dict(channel=self.channel_id), - reason=reason) - - async def get_channel_messages(self, - around: Snowflake = None, - before: Snowflake = None, - after: Snowflake = None, - limit: int = 50) -> list: - params = optional(**{ - 'around': around, - 'before': before, - 'after': after, - 'limit': limit - }) - - return await self.http.make_request(Routes.GET_CHANNEL_MESSAGES, - dict(channel=self.channel_id), - params=params) - - async def get_channel_message(self, message_id: Snowflake) -> dict: - return await self.http.make_request(Routes.GET_CHANNEL_MESSAGE, - dict(channel=self.channel_id, message=message_id)) - - async def create_message(self, - content: str = None, - nonce: Snowflake = None, - tts: bool = False, - files: list = None, - embed: dict = None) -> dict: - payload = optional(**{ - 'content': content, - 'nonce': nonce, - 'tts': tts, - 'embed': embed - }) - - if files: - if len(files) == 1: - attachments = { - 'file': tuple(files[0]), - } - else: - attachments = { - 'file{}'.format(index): tuple(file) for index, file in enumerate(files) - } - - return await self.http.make_request(Routes.CREATE_MESSAGE, - dict(channel=self.channel_id), - files=attachments, - data={'payload_json': json.dumps(payload)}) - - return await self.http.make_request(Routes.CREATE_MESSAGE, - dict(channel=self.channel_id), - json=payload) - - async def create_reaction(self, message_id: Snowflake, emoji: str): - return await self.http.make_request(Routes.CREATE_REACTION, - dict(channel=self.channel_id, - message=message_id, - emoji=self._parse_emoji(emoji))) - - async def delete_own_reaction(self, message_id: Snowflake, emoji: str): - return await self.http.make_request(Routes.DELETE_OWN_REACTION, - dict(channel=self.channel_id, - message=message_id, - emoji=self._parse_emoji(emoji))) - - async def delete_user_reaction(self, - message_id: Snowflake, - user_id: Snowflake, - emoji: str): - return await self.http.make_request(Routes.DELETE_USER_REACTION, - dict(channel=self.channel_id, - message=message_id, - emoji=self._parse_emoji(emoji), - user=user_id)) - - async def get_reactions(self, - message_id: Snowflake, - emoji: str, - before: Snowflake = None, - after: Snowflake = None, - limit: int = 25) -> dict: - params = optional(**{ - 'before': before, - 'after': after, - 'limit': limit - }) - - return await self.http.make_request(Routes.GET_REACTIONS, - dict(channel=self.channel_id, - message=message_id, - emoji=self._parse_emoji(emoji)), - params=params) - - async def delete_all_reactions(self, message_id: Snowflake): - return await self.http.make_request(Routes.DELETE_ALL_REACTIONS, - dict(channel=self.channel_id, message=message_id)) - - async def edit_message(self, - message_id: Snowflake, - content: str = None, - embed: dict = None) -> dict: - params = optional(**{ - 'content': content, - 'embed': embed, - }) - - return await self.http.make_request(Routes.EDIT_MESSAGE, - dict(channel=self.channel_id, message=message_id), - json=params) - - async def delete_message(self, message_id: Snowflake, reason: str = None): - return await self.http.make_request(Routes.DELETE_MESSAGE, - dict(channel=self.channel_id, message=message_id), - reason=reason) - - async def bulk_delete_messages(self, messages: List[Snowflake], reason: str = None): - if 2 <= len(messages) <= 100: - raise ValueError('Bulk delete requires a message count between 2 and 100') - - return await self.http.make_request(Routes.BULK_DELETE_MESSAGES, - dict(channel=self.channel_id), - json={'messages': messages}, - reason=reason) - - async def edit_channel_permissions(self, - overwrite_id: Snowflake, - allow: int = None, - deny: int = None, - type: str = None, - reason: str = None): - params = optional(**{ - 'allow': allow, - 'deny': deny, - 'type': type - }) - - if params.get('type', 'member') not in ('member', 'role'): - raise ValueError('Argument for type must be either "member" or "role"') - - return await self.http.make_request(Routes.EDIT_CHANNEL_PERMISSIONS, - dict(channel=self.channel_id, overwrite=overwrite_id), - json=params, - reason=reason) - - async def get_channel_invites(self) -> list: - return await self.http.make_request(Routes.GET_CHANNEL_INVITES, - dict(channel=self.channel_id)) - - async def create_channel_invite(self, - max_age: int = 86400, - max_uses: int = 0, - temporary: bool = False, - unique: bool = False, - reason: str = None) -> dict: - params = optional(**{ - 'max_age': max_age, - 'max_uses': max_uses, - 'temporary': temporary, - 'unique': unique - }) - - return await self.http.make_request(Routes.CREATE_CHANNEL_INVITE, - dict(channel=self.channel_id), - json=params, - reason=reason) - - async def delete_channel_permission(self, overwrite_id: Snowflake, reason: str = None): - return await self.http.make_request(Routes.DELETE_CHANNEL_PERMISSION, - dict(channel=self.channel_id, overwrite=overwrite_id), - reason=reason) - - async def trigger_typing_indicator(self): - return await self.http.make_request(Routes.TRIGGER_TYPING_INDICATOR, - dict(channel=self.channel_id)) - - async def get_pinned_messages(self) -> dict: - return await self.http.make_request(Routes.GET_PINNED_MESSAGES, - dict(channel=self.channel_id)) - - async def add_pinned_channel_message(self, message_id: Snowflake): - return await self.http.make_request(Routes.ADD_PINNED_CHANNEL_MESSAGE, - dict(channel=self.channel_id, message=message_id)) - - async def delete_pinned_channel_message(self, message_id: Snowflake, reason: str = None): - return await self.http.make_request(Routes.DELETE_PINNED_CHANNEL_MESSAGE, - dict(channel=self.channel_id, message=message_id), - reason=reason) - - async def group_dm_add_recipient(self, - user_id: Snowflake, - access_token: str = None, - nick: str = None): - params = optional(**{ - 'access_token': access_token, - 'nick': nick - }) - - return await self.http.make_request(Routes.GROUP_DM_ADD_RECIPIENT, - dict(channel=self.channel_id, user=user_id), - json=params) - - async def group_dm_remove_recipient(self, user_id: Snowflake): - return await self.http.make_request(Routes.GROUP_DM_REMOVE_RECIPIENT, - dict(channel=self.channel_id, user=user_id)) diff --git a/clamor/rest/endpoints/emoji.py b/clamor/rest/endpoints/emoji.py deleted file mode 100644 index 12e7db7..0000000 --- a/clamor/rest/endpoints/emoji.py +++ /dev/null @@ -1,64 +0,0 @@ -# -*- coding: utf-8 -*- - -from ..routes import Routes -from .base import * - -__all__ = ( - 'EmojiWrapper', -) - - -class EmojiWrapper(EndpointsWrapper): - """A higher-level wrapper around Emoji endpoints. - - .. seealso:: Emoji endpoints https://discordapp.com/developers/docs/resources/emoji - """ - - def __init__(self, token: str, guild_id: Snowflake): - super().__init__(token) - - self.guild_id = guild_id - - async def list_guild_emojis(self) -> list: - return await self.http.make_request(Routes.LIST_GUILD_EMOJIS, - dict(guild=self.guild_id)) - - async def get_guild_emoji(self, emoji_id: Snowflake) -> dict: - return await self.http.make_request(Routes.GET_GUILD_EMOJI, - dict(guild=self.guild_id, emoji=emoji_id)) - - async def create_guild_emoji(self, - name: str, - image: str, - roles: list, - reason: str = None) -> dict: - params = { - 'name': name, - 'image': image, - 'roles': roles - } - - return await self.http.make_request(Routes.CREATE_GUILD_EMOJI, - dict(guild=self.guild_id), - json=params, - reason=reason) - - async def modify_guild_emoji(self, - emoji_id: Snowflake, - name: str = None, - roles: list = None, - reason: str = None) -> dict: - params = optional(**{ - 'name': name, - 'roles': roles - }) - - return await self.http.make_request(Routes.MODIFY_GUILD_EMOJI, - dict(guild=self.guild_id, emoji=emoji_id), - json=params, - reason=reason) - - async def delete_guild_emoji(self, emoji_id: Snowflake, reason: str = None): - return await self.http.make_request(Routes.DELETE_GUILD_EMOJI, - dict(guild=self.guild_id, emoji=emoji_id), - reason=reason) diff --git a/clamor/rest/endpoints/gateway.py b/clamor/rest/endpoints/gateway.py deleted file mode 100644 index dc39de9..0000000 --- a/clamor/rest/endpoints/gateway.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*- coding: utf-8 -*- - -from ..routes import Routes -from .base import * - -__all__ = ( - 'GatewayWrapper', -) - - -class GatewayWrapper(EndpointsWrapper): - """A higher-level wrapper around Gateway endpoints. - - .. seealso:: Gateway endpoints https://discordapp.com/developers/docs/topics/gateway - """ - - async def get_gateway(self) -> dict: - return await self.http.make_request(Routes.GET_GATEWAY) - - async def get_gateway_bot(self) -> dict: - return await self.http.make_request(Routes.GET_GATEWAY_BOT) diff --git a/clamor/rest/endpoints/guild.py b/clamor/rest/endpoints/guild.py deleted file mode 100644 index 629f3f8..0000000 --- a/clamor/rest/endpoints/guild.py +++ /dev/null @@ -1,384 +0,0 @@ -# -*- coding: utf-8 -*- - -from ..routes import Routes -from .base import * - -__all__ = ( - 'GuildWrapper', -) - - -class GuildWrapper(EndpointsWrapper): - """A higher-level wrapper around Guild endpoints. - - .. seealso:: Guild endpoints https://discordapp.com/developers/docs/resources/guild - """ - - def __init__(self, token: str, guild_id: Snowflake): - super().__init__(token) - - self.guild_id = guild_id - - async def create_guild(self, - name: str, - region: str, - icon: str, - verification_level: int, - default_message_notifications: int, - explicit_content_filter: int, - roles: list, - channels: list) -> dict: - params = { - "name": name, - "region": region, - "icon": icon, - "verification_level": verification_level, - "default_message_notifications": default_message_notifications, - "explicit_content_filter": explicit_content_filter, - "roles": roles, - "channels": channels - } - - return await self.http.make_request(Routes.CREATE_GUILD, - json=params) - - async def get_guild(self) -> dict: - return await self.http.make_request(Routes.GET_GUILD, - dict(guild=self.guild_id)) - - async def modify_guild(self, - name: str = None, - region: str = None, - verification_level: int = None, - default_message_notifications: int = None, - explicit_content_filter: int = None, - afk_channel_id: Snowflake = None, - afk_timout: int = None, - icon: str = None, - owner_id: Snowflake = None, - splash: str = None, - system_channel_id: Snowflake = None, - reason: str = None) -> dict: - params = optional(**{ - "name": name, - "region": region, - "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_timout, - "icon": icon, - "owner_id": owner_id, - "splash": splash, - "system_channel_id": system_channel_id - }) - - return await self.http.make_request(Routes.MODIFY_GUILD, - dict(guild=self.guild_id), - json=params, - reason=reason) - - async def delete_guild(self): - return await self.http.make_request(Routes.DELETE_GUILD, - dict(guild=self.guild_id)) - - async def get_guild_channels(self) -> list: - return await self.http.make_request(Routes.GET_GUILD_CHANNELS, - dict(guild=self.guild_id)) - - async def create_guild_channel(self, - name: str, - channel_type: int = None, - topic: str = None, - bitrate: int = None, - user_limit: int = None, - rate_limit_per_user: int = None, - position: int = None, - permission_overwrites: list = None, - parent_id: Snowflake = None, - reason: str = None) -> dict: - params = optional(**{ - "name": name, - "channel_type": channel_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 - }) - - return await self.http.make_request(Routes.CREATE_GUILD_CHANNEL, - dict(guild=self.guild_id), - json=params, - reason=reason) - - async def modify_guild_channel_positions(self, channels: list): - return await self.http.make_request(Routes.MODIFY_GUILD_CHANNEL_POSITIONS, - dict(guild=self.guild_id), - json=channels) - - async def get_guild_member(self, user_id: Snowflake) -> dict: - return await self.http.make_request(Routes.GET_GUILD_MEMBER, - dict(guild=self.guild_id, member=user_id)) - - async def list_guild_members(self, - limit: int = None, - after: Snowflake = None) -> list: - params = optional(**{ - "limit": limit, - "after": after - }) - - return await self.http.make_request(Routes.LIST_GUILD_MEMBERS, - dict(guild=self.guild_id), - json=params) - - async def add_guild_member(self, - user_id: Snowflake, - access_token: str, - nick: str = None, - roles: list = None, - mute: bool = None, - deaf: bool = None) -> dict: - params = optional(**{ - "access_token": access_token, - "nick": nick, - "roles": roles, - "mute": mute, - "deaf": deaf - }) - - return await self.http.make_request(Routes.ADD_GUILD_MEMBER, - dict(guild=self.guild_id, member=user_id), - json=params) - - async def modify_guild_member(self, - user_id: Snowflake, - nick: str = None, - roles: list = None, - mute: bool = None, - deaf: bool = None, - channel_id: Snowflake = None, - reason: str = None): - params = optional(**{ - "nick": nick, - "roles": roles, - "mute": mute, - "deaf": deaf, - "channel_id": channel_id - }) - - return await self.http.make_request(Routes.MODIFY_GUILD_MEMBER, - dict(guild=self.guild_id, member=user_id), - json=params, - reason=reason) - - async def modify_current_user_nick(self, nick: str, reason: str = None) -> str: - params = { - "nick": nick - } - - return await self.http.make_request(Routes.MODIFY_CURRENT_USER_NICK, - dict(guild=self.guild_id), - json=params, - reason=reason) - - async def add_guild_member_role(self, - user_id: Snowflake, - role_id: Snowflake, - reason: str = None): - return await self.http.make_request(Routes.ADD_GUILD_MEMBER_ROLE, - dict(guild=self.guild_id, member=user_id, role=role_id), - reason=reason) - - async def remove_guild_member_role(self, - user_id: Snowflake, - role_id: Snowflake, - reason: str = None): - return await self.http.make_request(Routes.REMOVE_GUILD_MEMBER_ROLE, - dict(guild=self.guild_id, member=user_id, role=role_id), - reason=reason) - - async def remove_guild_member(self, user_id: Snowflake, reason: str = None): - return await self.http.make_request(Routes.REMOVE_GUILD_MEMBER, - dict(guild=self.guild_id, member=user_id), - reason=reason) - - async def get_guild_bans(self) -> list: - return await self.http.make_request(Routes.GET_GUILD_BANS, - dict(guild=self.guild_id)) - - async def get_guild_ban(self, user_id: Snowflake) -> dict: - return await self.http.make_request(Routes.GET_GUILD_BAN, - dict(guild=self.guild_id, user=user_id)) - - async def create_guild_ban(self, - user_id: Snowflake, - delete_message_days: int = None, - reason: str = None): - params = optional(**{ - "delete_message_days": delete_message_days, - "reason": reason - }) - - return await self.http.make_request(Routes.CREATE_GUILD_BAN, - dict(guild=self.guild_id, user=user_id), - json=params) - - async def remove_guild_ban(self, user_id: Snowflake, reason: str = None): - return await self.http.make_request(Routes.REMOVE_GUILD_BAN, - dict(guild=self.guild_id, user=user_id), - reason=reason) - - async def get_guild_roles(self): - return await self.http.make_request(Routes.GET_GUILD_ROLES, - dict(guild=self.guild_id)) - - async def create_guild_role(self, - name: str = None, - permissions: int = None, - color: int = None, - hoist: bool = None, - mentionable: bool = None, - reason: str = None) -> dict: - params = optional(**{ - "name": name, - "permissions": permissions, - "color": color, - "hoist": hoist, - "mentionable": mentionable - }) - - return await self.http.make_request(Routes.CREATE_GUILD_ROLE, - dict(guild=self.guild_id), - json=params, - reason=reason) - - async def modify_guild_role_positions(self, roles: list, reason: str = None) -> list: - return await self.http.make_request(Routes.MODIFY_GUILD_ROLE_POSITIONS, - dict(guild=self.guild_id), - json=roles, - reason=reason) - - async def modify_guild_role(self, - role_id: Snowflake, - name: str = None, - permissions: int = None, - color: int = None, - hoist: bool = None, - mentionable: bool = None, - reason: str = None) -> dict: - params = optional(**{ - "name": name, - "permissions": permissions, - "color": color, - "hoist": hoist, - "mentionable": mentionable - }) - - return await self.http.make_request(Routes.MODIFY_GUILD_ROLE, - dict(guild=self.guild_id, role=role_id), - json=params, - reason=reason) - - async def delete_guild_role(self, role_id: Snowflake, reason: str = None): - return await self.http.make_request(Routes.DELETE_GUILD_ROLE, - dict(guild=self.guild_id, role=role_id), - reason=reason) - - async def get_guild_prune_count(self) -> dict: - return await self.http.make_request(Routes.GET_GUILD_PRUNE_COUNT, - dict(guild=self.guild_id)) - - async def begin_guild_prune(self, - days: int, - compute_prune_count: bool) -> dict: - params = { - "days": days, - "compute_prune_count": compute_prune_count - } - - return await self.http.make_request(Routes.BEGIN_GUILD_PRUNE, - dict(guild=self.guild_id), - json=params) - - async def get_guild_voice_regions(self) -> list: - return await self.http.make_request(Routes.GET_GUILD_VOICE_REGIONS, - dict(guild=self.guild_id)) - - async def get_guild_invites(self) -> list: - return await self.http.make_request(Routes.GET_GUILD_INVITES, - dict(guild=self.guild_id)) - - async def get_guild_integrations(self) -> list: - return await self.http.make_request(Routes.GET_GUILD_INTEGRATIONS, - dict(guild=self.guild_id)) - - async def create_guild_integration(self, - int_type: str, - int_id: Snowflake, - reason: str = None): - params = { - "type": int_type, - "id": int_id - } - - return await self.http.make_request(Routes.CREATE_GUILD_INTEGRATION, - dict(guild=self.guild_id), - json=params, - reason=reason) - - async def modify_guild_integration(self, - integration_id: Snowflake, - expire_behavior: int, - expire_grace_period: int, - enable_emoticons: bool, - reason: str = None): - params = { - "expire_behavior": expire_behavior, - "expire_grace_period": expire_grace_period, - "enable_emoticons": enable_emoticons - } - - return await self.http.make_request(Routes.MODIFY_GUILD_INTEGRATION, - dict(guild=self.guild_id, integration=integration_id), - json=params, - reason=reason) - - async def delete_guild_integration(self, integration_id: Snowflake, reason: str = None): - return await self.http.make_request(Routes.DELETE_GUILD_INTEGRATION, - dict(guild=self.guild_id, integration=integration_id), - reason=reason) - - async def sync_guild_integration(self, integration_id: Snowflake): - return await self.http.make_request(Routes.SYNC_GUILD_INTEGRATION, - dict(guild=self.guild_id, integration=integration_id)) - - async def get_guild_embed(self) -> dict: - return await self.http.make_request(Routes.GET_GUILD_EMBED, - dict(guild=self.guild_id)) - - async def modify_guild_embed(self, - enabled: bool, - channel_id: Snowflake, - reason: str = None): - params = { - "enabled": enabled, - "channel_id": channel_id - } - - return await self.http.make_request(Routes.MODIFY_GUILD_EMBED, - dict(guild=self.guild_id), - json=params, - reason=reason) - - async def get_guild_vanity_url(self): - return await self.http.make_request(Routes.GET_GUILD_VANITY_URL, - dict(guild=self.guild_id)) - - async def get_guild_widget_image(self, style: str): - return await self.http.make_request(Routes.GET_GUILD_WIDGET_IMAGE, - dict(guild=self.guild_id), - json={"style": style}) diff --git a/clamor/rest/endpoints/invite.py b/clamor/rest/endpoints/invite.py deleted file mode 100644 index f19fca5..0000000 --- a/clamor/rest/endpoints/invite.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- coding: utf-8 -*- - -from ..routes import Routes -from .base import * - -__all__ = ( - 'InviteWrapper', -) - - -class InviteWrapper(EndpointsWrapper): - """A higher-level wrapper around Invite endpoints. - - .. seealso:: Invite endpoints https://discordapp.com/developers/docs/resources/invite - """ - - async def get_invite(self, invite_code: str, with_counts: bool = False) -> dict: - return await self.http.make_request(Routes.GET_INVITE, - dict(invite=invite_code), - params=optional(**{'with_counts': with_counts})) - - async def delete_invite(self, invite_code: str, reason: str = None) -> dict: - return await self.http.make_request(Routes.DELETE_INVITE, - dict(invite=invite_code), - reason=reason) diff --git a/clamor/rest/endpoints/oauth.py b/clamor/rest/endpoints/oauth.py deleted file mode 100644 index dc179dc..0000000 --- a/clamor/rest/endpoints/oauth.py +++ /dev/null @@ -1,18 +0,0 @@ -# -*- coding: utf-8 -*- - -from ..routes import Routes -from .base import * - -__all__ = ( - 'OAuthWrapper', -) - - -class OAuthWrapper(EndpointsWrapper): - """A higher-level wrapper around OAuth2 endpoints. - - .. seealso:: OAuth2 endpoints https://discordapp.com/developers/docs/topics/oauth2 - """ - - async def get_current_application_info(self) -> dict: - return await self.http.make_request(Routes.GET_CURRENT_APPLICATION_INFO) diff --git a/clamor/rest/endpoints/user.py b/clamor/rest/endpoints/user.py deleted file mode 100644 index 1b09ec0..0000000 --- a/clamor/rest/endpoints/user.py +++ /dev/null @@ -1,85 +0,0 @@ -# -*- coding: utf-8 -*- - -from typing import List, Optional - -from ..routes import Routes -from .base import * - -__all__ = ( - 'UserWrapper', -) - - -class UserWrapper(EndpointsWrapper): - """A higher-level wrapper around User endpoints. - - .. seealso:: User endpoints https://discordapp.com/developers/docs/resources/user - """ - - @staticmethod - def _check_username(username: str) -> Optional[str]: - if not username: - return None - - if 2 > len(username) > 32: - raise ValueError('Usernames must be beween 2 and 32 characters long') - - if username in ('discordtag', 'everyone', 'here'): - raise ValueError('Restricted username') - - if any(c in ('@', '#', ':', '```') for c in username): - raise ValueError('Usernames must not contain "@", "#", ":" or "```"') - - return username.strip() - - async def get_current_user(self) -> dict: - return await self.http.make_request(Routes.GET_CURRENT_USER) - - async def get_user(self, user_id: Snowflake) -> dict: - return await self.http.make_request(Routes.GET_USER, - dict(user=user_id)) - - async def modify_current_user(self, username: str = None, avatar: str = None) -> dict: - params = optional(**{ - 'username': self._check_username(username), - 'avatar': avatar - }) - - return await self.http.make_request(Routes.MODIFY_CURRENT_USER, - json=params) - - async def get_current_user_guilds(self, - before: Snowflake = None, - after: Snowflake = None, - limit: int = 100) -> list: - params = optional(**{ - 'before': before, - 'after': after, - 'limit': limit - }) - - return await self.http.make_request(Routes.GET_CURRENT_USER_GUILDS, - params=params) - - async def leave_guild(self, guild_id: Snowflake): - return await self.http.make_request(Routes.LEAVE_GUILD, - dict(guild=guild_id)) - - async def get_user_dms(self) -> list: - return await self.http.make_request(Routes.GET_USER_DMS) - - async def create_dm(self, recipient_id: Snowflake) -> dict: - return await self.http.make_request(Routes.CREATE_DM, - json={'recipient_id': recipient_id}) - - async def create_group_dm(self, access_tokens: List[str], nicks: dict) -> dict: - params = { - 'access_tokens': access_tokens, - 'nicks': nicks, - } - - return await self.http.make_request(Routes.CREATE_GROUP_DM, - json=params) - - async def get_user_connections(self) -> list: - return await self.http.make_request(Routes.GET_USER_CONNECTIONS) diff --git a/clamor/rest/endpoints/voice.py b/clamor/rest/endpoints/voice.py deleted file mode 100644 index 72a0883..0000000 --- a/clamor/rest/endpoints/voice.py +++ /dev/null @@ -1,18 +0,0 @@ -# -*- coding: utf-8 -*- - -from ..routes import Routes -from .base import * - -__all__ = ( - 'VoiceWrapper', -) - - -class VoiceWrapper(EndpointsWrapper): - """A higher-level wrapper around Voice endpoints. - - .. seealso:: Voice endpoints https://discordapp.com/developers/docs/resources/voice - """ - - async def list_voice_regions(self) -> list: - return await self.http.make_request(Routes.LIST_VOICE_REGIONS) diff --git a/clamor/rest/endpoints/webhook.py b/clamor/rest/endpoints/webhook.py deleted file mode 100644 index e2b7bd6..0000000 --- a/clamor/rest/endpoints/webhook.py +++ /dev/null @@ -1,148 +0,0 @@ -# -*- coding: utf-8 -*- - -import json -from typing import Optional - -from ..routes import Routes -from .base import * - -__all__ = ( - 'WebhookWrapper', -) - - -class WebhookWrapper(EndpointsWrapper): - """A higher-level wrapper around Webhook endpoints. - - .. seealso:: Webhook endpoints https://discordapp.com/developers/docs/resources/webhook - """ - - @staticmethod - def _check_name(name: str) -> Optional[str]: - if 2 > len(name) > 32: - raise ValueError('Name must be between 2 and 32 characters long') - - return name.strip() - - async def create_webhook(self, - channel_id: Snowflake, - name: str, - avatar: str = None, - reason: str = None) -> dict: - params = { - 'name': self._check_name(name), - 'avatar': avatar, - } - - return await self.http.make_request(Routes.CREATE_WEBHOOK, - dict(channel=channel_id), - json=params, - reason=reason) - - async def get_channel_webhooks(self, channel_id: Snowflake) -> list: - return await self.http.make_request(Routes.GET_CHANNEL_WEBHOOKS, - dict(channel=channel_id)) - - async def get_guild_webhooks(self, guild_id: Snowflake) -> list: - return await self.http.make_request(Routes.GET_GUILD_WEBHOOKS, - dict(guild=guild_id)) - - async def get_webhook(self, webhook_id: Snowflake) -> dict: - return await self.http.make_request(Routes.GET_WEBHOOK, - dict(webhook=webhook_id)) - - async def get_webhook_with_token(self, webhook_id: Snowflake, webhook_token: str) -> dict: - return await self.http.make_request(Routes.GET_WEBHOOK_WITH_TOKEN, - dict(webhook=webhook_id, token=webhook_token)) - - async def modify_webhook(self, - webhook_id: Snowflake, - name: str = None, - avatar: str = None, - channel_id: Snowflake = None, - reason: str = None) -> dict: - params = optional(**{ - 'name': self._check_name(name), - 'avatar': avatar, - 'channel_id': channel_id - }) - - return await self.http.make_request(Routes.MODIFY_WEBHOOK, - dict(webhook=webhook_id), - json=params, - reason=reason) - - async def modify_webhook_with_token(self, - webhook_id: Snowflake, - webhook_token: str, - name: str = None, - avatar: str = None, - reason: str = None) -> dict: - params = optional(**{ - 'name': self._check_name(name), - 'avatar': avatar - }) - - return await self.http.make_request(Routes.MODIFY_WEBHOOK_WITH_TOKEN, - dict(webhook=webhook_id, token=webhook_token), - json=params, - reason=reason) - - async def delete_webhook(self, webhook_id: Snowflake, reason: str = None): - return await self.http.make_request(Routes.DELETE_WEBHOOK, - dict(webhook=webhook_id), - reason=reason) - - async def delete_webhook_with_token(self, - webhook_id: Snowflake, - webhook_token: str, - reason: str = None): - return await self.http.make_request(Routes.DELETE_WEBHOOK_WITH_TOKEN, - dict(webhook=webhook_id, token=webhook_token), - reason=reason) - - async def execute_webhook(self, - webhook_id: Snowflake, - webhook_token: str, - content: str = None, - username: str = None, - avatar_url: str = None, - tts: bool = False, - files: list = None, - embeds: list = None, - wait: bool = False): - if not content and not files and not embeds: - raise ValueError('At least one of content, files or embeds is required') - - payload = optional(**{ - 'content': content, - 'username': username, - 'avatar_url': avatar_url, - 'tts': tts, - 'embeds': embeds - }) - - params = optional(**{ - 'wait': wait - }) - - if files: - if len(files) == 1: - attachments = { - 'file': tuple(files[0]), - } - else: - attachments = { - 'file{}'.format(index): tuple(file) for index, file in enumerate(files) - } - - return await self.http.make_request(Routes.EXECUTE_WEBHOOK, - dict(webhook=webhook_id, token=webhook_token), - files=attachments, - data={'payload_json': json.dumps(payload)}, - params=params) - - return await self.http.make_request(Routes.EXECUTE_WEBHOOK, - dict(webhook=webhook_id, token=webhook_token), - json=payload, - params=params) diff --git a/clamor/rest/http.py b/clamor/rest/http.py index 3d7c564..2ee11e8 100644 --- a/clamor/rest/http.py +++ b/clamor/rest/http.py @@ -12,7 +12,7 @@ from ..exceptions import RequestFailed, Unauthorized, Forbidden, NotFound from ..meta import __url__ as clamor_url, __version__ as clamor_version -from .rate_limit import Bucket, RateLimiter +from .rate_limit import Bucket, RateLimiter, InMemoryBucketStore from .routes import APIRoute __all__ = ( @@ -47,6 +47,9 @@ class HTTP: app : str The application type for the ``Authorization`` header. Either ``Bot`` or ``Bearer``, defaults to ``Bot``. + bucket_store: :class:`~clamor.rest.rate_limit.BucketStore` + The bucket store which will be used by the RateLimiter. + If no bucket store is provided, the RateLimiter will store the buckets in memory. Attributes ---------- @@ -71,7 +74,7 @@ class HTTP: def __init__(self, token: str, **kwargs): self._token = token self._session = kwargs.get('session', asks.Session()) - self.rate_limiter = RateLimiter() + self.rate_limiter = RateLimiter(kwargs.get('bucket_store')) self._responses = [] self.headers = { @@ -99,15 +102,17 @@ def responses(self): return self._responses @staticmethod - def _parse_response(response: Response) -> Optional[Union[dict, list, str]]: + def _parse_response(response: Response) -> Optional[Union[dict, list, str, bytes]]: if response.headers['Content-Type'] == 'application/json': return response.json(encoding='utf-8') + if response.headers['Content-Type'].startswith("image"): + return response.content return response.text.encode('utf-8') async def make_request(self, route: APIRoute, fmt: dict = None, - **kwargs) -> Optional[Union[dict, list, str]]: + **kwargs) -> Optional[Union[dict, list, str, bytes]]: r"""Makes a request to a given route with a set of arguments. It also handles rate limits, non-success status codes and @@ -204,7 +209,7 @@ async def make_request(self, async def parse_response(self, bucket: Bucket, - response: Response) -> Optional[Union[dict, list, str]]: + response: Response) -> Optional[Union[dict, list, str, bytes]]: """Parses a given response and handles non-success status codes. Parameters diff --git a/clamor/rest/rate_limit.py b/clamor/rest/rate_limit.py index 1c5891a..e90d5e6 100644 --- a/clamor/rest/rate_limit.py +++ b/clamor/rest/rate_limit.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import logging +import abc from datetime import datetime, timezone from email.utils import parsedate_to_datetime from typing import NewType, Tuple, Union @@ -9,6 +10,15 @@ from async_generator import async_generator, asynccontextmanager, yield_ from asks.response_objects import Response +import base4 +import json + +try: + import redis + INCLUDE_REDIS = True +except ModuleNotFoundError: + INCLUDE_REDIS = False + __all__ = ( 'Bucket', 'CooldownBucket', @@ -110,6 +120,98 @@ async def cooldown(self) -> float: return delay +class BucketStore(abc.ABC): + """A bucket store to store buckets. + + This class is used to store RateLimiting buckets. + This makes it possible to store buckets in other ways. + For example in Redis. + + """ + + @abc.abstractmethod + def store_bucket(key: Bucket, value: CooldownBucket): + pass + + @abc.abstractmethod + def get_bucket(bucket: Bucket) -> CooldownBucket: + pass + + @abc.abstractmethod + def delete_bucket(bucket: Bucket): + pass + + @abc.abstractmethod + def has_bucket(bucket: Bucket) -> bool: + pass + + def __getitem__(self, bucket: Bucket) -> CooldownBucket: + return self.get_bucket(bucket) + + def __setitem__(self, key: Bucket, value: CooldownBucket): + self.store_bucket(key, value) + + def __delitem__(self, bucket: Bucket): + self.delete_bucket(bucket) + + def __contains__(self, bucket: Bucket) -> bool: + return self.has_bucket(bucket) + + +class InMemoryBucketStore(BucketStore): + """A BucketStore which stores the bucket in-memory via a dict + + This bucket store is the default store which will be used by the + RateLimiter to store buckets. + + """ + + def __init__(self): + self._buckets = {} + + def store_bucket(self, key: Bucket, value: CooldownBucket): + self._buckets[key] = value + + def get_bucket(self, bucket: Bucket) -> CooldownBucket: + return self._buckets[bucket] + + def delete_bucket(self, bucket: Bucket): + del self._buckets[bucket] + + def has_bucket(self, bucket: Bucket) -> bool: + return bucket in self._buckets + + +if INCLUDE_REDIS: + class RedisBucketStore(BucketStore): + """A bucket store which stores the bucket in a redis database. + + This class is only available if you have [redis-py](https://pypi.org/project/redis/) + installed. + + Parameters + ---------- + **kwargs : dict + Look at the [redis-py](https://pypi.org/project/redis/) + documentation to see all Keyowrd arguments + """ + + def __init__(self, **kwargs): + self.redis_client = redis.Redis(**kwargs) + + def store_bucket(self, key: Bucket, value: CooldownBucket): + pass + + def get_bucket(self, bucket: Bucket) -> CooldownBucket: + pass + + def delete_bucket(self, bucket: Bucket): + pass + + def has_bucket(self, bucket: Bucket) -> bool: + pass + + class RateLimiter: """A rate limiter to keep track of per-bucket rate limits. @@ -119,7 +221,8 @@ class RateLimiter: can be used for that. It can also be used as an async contextmanager. - Buckets are stored in a dictionary as literal bucket and + Buckets are stored in the bucket store passed in the constructor. + If no bucket store is specified, the buckets are stored in memory. :class:`~clamor.rest.rate_limit.CooldownBucket` objects. .. code-block:: python3 @@ -163,8 +266,8 @@ class RateLimiter: Separate lock for global rate limits. """ - def __init__(self): - self._buckets = {} + def __init__(self, bucket_store=None): + self._buckets = bucket_store or InMemoryBucketStore() self.global_lock = anyio.create_lock() @asynccontextmanager diff --git a/clamor/utils/__init__.py b/clamor/utils/__init__.py new file mode 100644 index 0000000..888c4c2 --- /dev/null +++ b/clamor/utils/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from .emitter import * diff --git a/clamor/utils/cache.py b/clamor/utils/cache.py new file mode 100644 index 0000000..c3d4dc1 --- /dev/null +++ b/clamor/utils/cache.py @@ -0,0 +1,49 @@ +from collections import deque +from typing import Callable +from weakref import WeakValueDictionary + +from clamor import Base + + +class CacheCategory: + + def __init__(self, max: int, fallback: Callable, *ids: str): + self.fallback = fallback + self.ids = ids + self.active = WeakValueDictionary() + self.cached = deque(maxlen=max) + + def add_active(self, obj: Base): + key = tuple(getattr(obj, id) for id in self.ids) + self.active[key] = obj + + def get(self, *ids): + cached_obj = self.active.get(ids) \ + or next((obj for obj in self.cached if + all(getattr(obj, id, None) == ids[i] for i, id in + enumerate(self.ids))), None) + if cached_obj is None: + return self.fallback(*ids) + return cached_obj + + +class Cache: + + MAX_CACHE = 25 + + def __init__(self, client): + self.client = client + self.categories = { + "Guild": CacheCategory(self.MAX_CACHE, + self.client.api.get_guild, + "id"), + "Webhook": CacheCategory(self.MAX_CACHE, + self.client.api.get_webhook, + "id"), + "Channel": CacheCategory(self.MAX_CACHE, + self.client.api.get_channel, + "id") + } + + def get(self, category: str, *ids: int): + return self.categories[category].get(*ids) diff --git a/clamor/utils/emitter.py b/clamor/utils/emitter.py new file mode 100644 index 0000000..ce3aa8a --- /dev/null +++ b/clamor/utils/emitter.py @@ -0,0 +1,208 @@ +# -*- coding: utf-8 -*- + +from collections import defaultdict +from enum import Enum +from inspect import iscoroutinefunction +from functools import wraps +from typing import Any, Callable, Coroutine + +from clamor.exceptions import InvalidListener + +from anyio import create_task_group + +__all__ = ( + 'check_coroutine', + 'Emitter', + 'ListenerPod', + 'Priority', +) + + +def check_coroutine(func): + @wraps(func) + def wrapper(self, listener: Callable[..., Coroutine[Any, Any, None]]): + if not iscoroutinefunction(listener): + raise InvalidListener("Listener must be a coroutine") + return func(self, listener) + + return wrapper + + +class Priority(Enum): + BEFORE = 0 + NORMAL = 1 + AFTER = 2 + + +class ListenerPod: + """Event listener module. + + Listeners that all follow a certain event will exist in the same pod. Pods will separate + listeners into self-explanatory categories, before, normal, and after. The listeners will + trigger in order from before to after, with each listener triggering synchronously with + listeners from the same category. + + Attributes + ---------- + before : set + Listeners that will trigger first. + normal : set + Listeners that will trigger second. + after : set + Listeners that will trigger third. + """ + + def __init__(self): + self.before = set() + self.normal = set() + self.after = set() + + def __bool__(self): + return bool(self.before) or bool(self.normal) or bool(self.after) + + @check_coroutine + def add_before(self, listener: Callable[..., Coroutine[Any, Any, None]]): + """Adds a listener (Before). + + Adds a coroutine to the before category. + + Parameters + ---------- + listener : Coroutine + A coroutine to be triggered on its respective event. + """ + self.before.add(listener) + + @check_coroutine + def add_normal(self, listener: Callable[..., Coroutine[Any, Any, None]]): + """Adds a listener (Normal). + + Adds a coroutine to the normal category. + + Parameters + ---------- + listener : Coroutine + A coroutine to be triggered on its respective event. + """ + self.normal.add(listener) + + @check_coroutine + def add_after(self, listener: Callable[..., Coroutine[Any, Any, None]]): + """Adds a listener (After). + + Adds a coroutine to the after category. + + Parameters + ---------- + listener : Coroutine + A coroutine to be triggered on its respective event. + """ + self.after.add(listener) + + async def emit(self, *args): + """Trigger listeners in the pod + + All listeners in the before category will be spawned with the appropriate payload, and + once all those have finished, the normal category is triggered, and then the after category. + + Parameters + ---------- + args + The arguments to be distributed. + """ + async with create_task_group() as tg: + for listener in self.before: + await tg.spawn(listener, *args) + async with create_task_group() as tg: + for listener in self.normal: + await tg.spawn(listener, *args) + async with create_task_group() as tg: + for listener in self.after: + await tg.spawn(listener, *args) + + +class Emitter: + """Main event emitter. + + This is what orchestrates all the event pods, adds listeners, removes them and triggers events. + Events can be anything, for example ints, strings or enum members. + + Attributes + ---------- + listeners : defaultdict(:class `~clamor.utils.emitter.ListenerPod`:) + A default dict that holds event names to listener pods. + """ + + def __init__(self): + self.listeners = defaultdict(ListenerPod) + + def add_listener(self, event: Any, + listener: Callable[..., Coroutine[Any, Any, None]], + order: Priority = Priority.NORMAL): + """Adds a listener. + + Adds a listener to the correct pod and category, which by default is the normal priority. + + Parameters + ---------- + event : :class:`typing.Any` + The event to listen to. + listener : Coroutine + A coroutine to be triggered on its respective event. + order : :class:`~clamor.utils.emitter.Priority` + The order this listener should be triggered in. + """ + + # Create a pod if one does not exist for the event, then add the listener + # using the respective method, based on the priority. + getattr(self.listeners[event], "add_" + order.name.lower())(listener) + + async def emit(self, event: Any, *args): + """Emits an event. + + Triggers the corresponding :class:`~clamor.utils.emitter.ListenerPod` if one exists. + + Parameters + ---------- + event: :class:`typing.Any` + The event to listen to. + args + The arguments to be distributed. + """ + + if self.listeners[event]: + await self.listeners[event].emit(*args) + + def clear_event(self, event: Any): + """Clears all listeners. + + Removes all listeners, to matter the category, from the provided event. + + Parameters + ---------- + event : :class:`typing.Any` + The event to remove. + """ + + self.listeners.pop(event) + + def remove_listener(self, event: Any, listener: Callable[..., Coroutine[Any, Any, None]]): + """Removes a specific listener from an event. + + Removes a the provided listener from an event, no matter the category. + + Parameters + ---------- + event : :class:`typing.Any` + The event to search. + listener : Coroutine + The listener to remove. + """ + + if event in self.listeners: + if listener in self.listeners[event].before: + self.listeners[event].before.remove(listener) + if listener in self.listeners[event].normal: + self.listeners[event].normal.remove(listener) + if listener in self.listeners[event].after: + self.listeners[event].after.remove(listener) diff --git a/clamor/utils/files.py b/clamor/utils/files.py new file mode 100644 index 0000000..d5cb27f --- /dev/null +++ b/clamor/utils/files.py @@ -0,0 +1,113 @@ +from base64 import b64encode +from io import IOBase +from typing import Union, Optional + +import asks + + +class File: + """ + Files that are discord usable + + Discord requires that files be sent in specific ways, so we can't just stuff a random file into + a request and expect it to be attached. To solve this issue, you can use this class to take + in a file, or it can download a file from a url. Then it will turn whatever file you put in + into the kind of file discord expects. + + Attributes + ---------- + data : bytes + The file contents in bytes + file_type : str + The file extension (png, txt, pdf, etc) + file_name : str + The name of the file, excluding the extension + data_url : str + The file in the data URI scheme (file type must be set) + name : str + The full name of the file (name + extension) + """ + + def __init__(self, data: bytes, file_type: Optional[str] = None, file_name: str = "file"): + self.data = data + self.file_type = file_type + self.file_name = file_name + + @property + def data_uri(self): + return "data:image/" + self.file_type + ";base64," + b64encode(self.data).decode() + + @property + def name(self): + return self.file_name + "." + (self.file_type or "") + + @classmethod + def from_file(cls, file: Union[str, IOBase]): + """ + Create discord file from local file + + Generates a File class from a local file. You can provide either the path to the file, or + the file object itself. + + Parameters + ---------- + file : str or io.IOBase + A path to a file, or a file object (Not just any stream!) + + Returns + ------- + :class:`clamor.utils.files.File` + A discord usable file object + """ + if isinstance(file, str): + with open(file, "rb") as new_file: + return cls.from_file(new_file) + + if '.' not in file.name: + name, extension = file.name, None + else: + name, extension = file.name.rsplit(".", 1) + return cls(file.read(), extension, name) + + @classmethod + async def from_url(cls, target_url: str, file_name: str = None): + """ + Create a discord file from url + + This function will download a file from the provided URL, and return a discord usable file + based off that. Because files need to have names, this function has several different + strategies to solve that issue. First, if a file name is provided, it will use that. If the + provided file name doesn't have an extension it will check the response's Content-Type + header. If a name is not provided at all, it will use the name from the url path. If the + path doesn't have an extension it will once again check the Content-Type header. If all + else fails your computer will in fact explode and you should have provided a file name. + + Parameters + ---------- + target_url : str + The place we'll download the file from + file_name : str or None + The file name + + Returns + ------- + :class:`clamor.utils.files.File` + A discord usable file object + """ + resp = await asks.get(target_url) + if file_name: + if "." not in file_name and "Content-Type" in resp.headers: + name, extension = file_name, resp.headers['Content-Type'].split("/")[1] + elif "." not in file_name: + name, extension = file_name, None + else: + name, extension = file_name.rsplit(".", 1) + else: + _, url_file = target_url.rsplit("/", 1) + if "." not in url_file and "Content-Type" in resp.headers: + name, extension = url_file, resp.headers['Content-Type'].split("/")[1] + elif "." not in file_name: + name, extension = url_file, None + else: + name, extension = file_name.rsplit(".", 1) + return cls(resp.content, extension, file_name) diff --git a/clamor/utils/parse.py b/clamor/utils/parse.py new file mode 100644 index 0000000..6cc2389 --- /dev/null +++ b/clamor/utils/parse.py @@ -0,0 +1,36 @@ +from typing import Optional + +import re + +EMOJI_REGEX = re.compile(r'') + + +def parse_emoji(emoji: str) -> str: + match = EMOJI_REGEX.match(emoji) + if match: + emoji = match.group(1) + + return emoji + + +def check_username(username: str) -> Optional[str]: + if not username: + return None + + if 2 > len(username) > 32: + raise ValueError('Usernames must be beween 2 and 32 characters long') + + if username in ('discordtag', 'everyone', 'here'): + raise ValueError('Restricted username') + + if any(c in ('@', '#', ':', '```') for c in username): + raise ValueError('Usernames must not contain "@", "#", ":" or "```"') + + return username.strip() + + +def check_webhook_name(name: str) -> Optional[str]: + if 2 > len(name) > 32: + raise ValueError('Name must be between 2 and 32 characters long') + + return name.strip() diff --git a/tests/test_gateway_connecting.py b/tests/test_gateway_connecting.py new file mode 100644 index 0000000..2230456 --- /dev/null +++ b/tests/test_gateway_connecting.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- + +import os +import unittest + +import anyio + +from clamor import gateway, HTTP, Routes + + +class GatewayTests(unittest.TestCase): + def gateway_connect(self, compressed: bool): + async def main(): + http = HTTP(os.environ['TEST_BOT_TOKEN']) + url = await http.make_request(Routes.GET_GATEWAY_BOT) + + gw = gateway.DiscordWebsocketClient(url['url'], shard_id=0, shard_count=1, + zlib_compressed=compressed) + connected = False + self.assertIsInstance(gw, gateway.DiscordWebsocketClient) + + async def set_connected(_): + nonlocal connected + connected = True + + gw.emitter.add_listener(gateway.Opcode.DISPATCH, set_connected) + + async def stop_gateway(after): + await anyio.sleep(after) + await gw.close() + + async with anyio.create_task_group() as tg: + await tg.spawn(gw.start, os.environ['TEST_BOT_TOKEN']) + await tg.spawn(stop_gateway, 10) + + self.assertTrue(connected) + + anyio.run(main) + + def test_normal_gateway_connect(self): + self.gateway_connect(False) + + def test_compressed_gateway_connect(self): + self.gateway_connect(True) diff --git a/tests/test_gateway_sharding.py b/tests/test_gateway_sharding.py new file mode 100644 index 0000000..7c518e6 --- /dev/null +++ b/tests/test_gateway_sharding.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- + +import unittest +import os + +import anyio + +from clamor import gateway, HTTP, Routes + + +class GatewayTests(unittest.TestCase): + def test_gateway_connect(self): + async def main(): + http = HTTP(os.environ['TEST_BOT_TOKEN']) + url = await http.make_request(Routes.GET_GATEWAY) + + gw_one = gateway.DiscordWebsocketClient(url['url'], shard_id=0, shard_count=2) + gw_two = gateway.DiscordWebsocketClient(url['url'], shard_id=1, shard_count=2) + + self.assertIsInstance(gw_one, gateway.DiscordWebsocketClient) + self.assertIsInstance(gw_two, gateway.DiscordWebsocketClient) + + async def stop_gatways(after): + await anyio.sleep(after) + await gw_one.close() + await gw_two.close() + + async with anyio.create_task_group() as tg: + await tg.spawn(gw_one.start, os.environ['TEST_BOT_TOKEN']) + await tg.spawn(gw_two.start, os.environ['TEST_BOT_TOKEN']) + await tg.spawn(stop_gatways, 10) + + anyio.run(main) diff --git a/tests/test_rest_rate_limit.py b/tests/test_rest_rate_limit.py index 23e7f7a..f4ce537 100644 --- a/tests/test_rest_rate_limit.py +++ b/tests/test_rest_rate_limit.py @@ -10,7 +10,7 @@ from clamor import RateLimiter -class RateLimitTests(unittest.TestCase): +class HTTPTests(unittest.TestCase): def test_rate_limiter(self): async def main(): limiter = RateLimiter() diff --git a/tests/test_utils_emitter.py b/tests/test_utils_emitter.py new file mode 100644 index 0000000..84b9a84 --- /dev/null +++ b/tests/test_utils_emitter.py @@ -0,0 +1,59 @@ +import unittest + +from anyio import run +from clamor import Emitter, Priority + + +class TestEmitter(unittest.TestCase): + + def test_main_functionality(self): + async def main(): + emitter = Emitter() + goal = [] + + async def early(): + goal.append(1) + + async def timely(): + goal.append(2) + + async def lately(): + goal.append(3) + + emitter.add_listener("test", early, Priority.BEFORE) + emitter.add_listener("test", timely) + emitter.add_listener("test", lately, Priority.AFTER) + for _ in range(20): # Make sure it wasn't an accident + await emitter.emit("test") + self.assertEqual(goal, [1, 2, 3]) + goal.clear() + + run(main) + + def test_removal(self): + async def main(): + emitter = Emitter() + goal = [] + + async def early(): + goal.append(1) + + async def timely(): + goal.append(2) + + async def lately(): + goal.append(3) + + emitter.add_listener("test", early, Priority.BEFORE) + emitter.add_listener("test", timely) + emitter.add_listener("test", lately, Priority.AFTER) + await emitter.emit("test") + self.assertEqual(len(goal), 3) + goal.clear() + emitter.remove_listener("test", early) + await emitter.emit("test") + self.assertEqual(len(goal), 2) + emitter.clear_event("test") + self.assertFalse(emitter.listeners['test']) + + run(main)