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)