Browse Source

[refactor] Kill Holster (#132)

* First stab at killing holster and getting tests passing

* Fix some things from my brief smoketest
pull/136/head
Andrei Zbikowski 6 years ago
committed by GitHub
parent
commit
bb9e6bb356
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 118
      CHANGELOG.md
  2. 3
      disco/api/client.py
  3. 23
      disco/api/http.py
  4. 2
      disco/bot/bot.py
  5. 16
      disco/bot/command.py
  6. 2
      disco/bot/plugin.py
  7. 3
      disco/client.py
  8. 4
      disco/gateway/client.py
  9. 4
      disco/gateway/encoding/base.py
  10. 21
      disco/gateway/ipc.py
  11. 32
      disco/gateway/packets.py
  12. 4
      disco/state.py
  13. 37
      disco/types/base.py
  14. 28
      disco/types/channel.py
  15. 88
      disco/types/guild.py
  16. 21
      disco/types/message.py
  17. 79
      disco/types/permissions.py
  18. 51
      disco/types/user.py
  19. 162
      disco/util/emitter.py
  20. 27
      disco/util/threadlocal.py
  21. 3
      disco/util/websocket.py
  22. 41
      disco/voice/client.py
  23. 30
      disco/voice/opus.py
  24. 31
      disco/voice/packets.py
  25. 17
      disco/voice/player.py
  26. 48
      disco/voice/udp.py
  27. 2
      docs/bot_tutorial/building_block_listeners.md
  28. 1
      requirements.txt
  29. 2
      tests/state.py
  30. 10
      tests/types/types.py

118
CHANGELOG.md

@ -1,118 +0,0 @@
# CHANGELOG
## v0.0.12
### Additions
- **MAJOR** Added voice gateway v3 support. This will result in increased stability for voice connections
- **BREAKING** Updated holster to v2.0.0 which changes the way emitters work (and removes the previous priorities). A migration guide will be provided post-RC cycle.
- Added support for ETF on Python 3.x via `earl-etf` (@GiovanniMCMXCIX)
- Supported detecting dead/inactive/zombied Gateway websocket connections via tracking `HEARTBEAT_ACK` (@PixeLInc)
- Added support for animated emoji (@Seklfreak)
- Added support for `LISTENING` and `WATCHING` game statuses (@PixeLInc)
- Added `wsaccel` package within the `performance` pack, should improve websocket performance
- Added the concept of a `shared_config` which propgates its options to all plugin configs (@enkoder)
- Added support for streaming zlib compression to our gateway socket. This is enabled by default and provides significant performance improvements on startup and overall bandwidth usage
- Added support for `Guild.system_channel_id` and `GUILD_MEMBER_JOIN` system message
- Added `Guild.create_category`, `Guild.create_text_channel` and `Guild.create_voice_channel`
- Added `Channel.create_text_channel` and `Channel.create_voice_channel` which can be called only on category channels to add sub-channels
### Fixes
- Fixed 'Invalid token passed' errors from showing up (via removal of token validation)
- Fixed `IndexError` being raised when `MessageIterator` was done iterating (@Majora320)
- Fixed overwrite calculations in `Channel.get_permissions` (@cookkkie)
- A plethora of PEP8 and general syntax changes have been made to cleanup the code
- Fixed a bug with `Emoji.custom`
- Fixed a bug in the typing system that would not allow Field's to have a `default` of `None`
- Fixed the `__str__` method for Channel's displaying (useless) unset data for DMs
- Fixed a bug with `MessageIterator` related to iterating before or after an ID of 0
- Fixed incorrect field name (`icon_proxy_url` vs `proxy_icon_url`) in MessageEmbedAuthor model
- Fixed bugs related to creating and deleting pinned messages
- Fixed `GuildBan.reason` incorrectly handling unicode reasons
- Fixed `Paginator` throwing an exception when reaching the end of pagination, instead of just ending its iteration
- Fixed `Paginator` defaulting to start at 0 for all iterations
### Etc
- **BREAKING** Refactor the way Role's are managed and updated. You should update your code to use `Role.update`
- **BREAKING** Renamed `Model.update` to `Model.inplace_update`. You should not have to worry about this change unless you explicitly call that method
- **DEPRECATION** Deprecated the use of `Guild.create_channel`. You should use the explicit channel type creation methods added in this release
- Cleaned up various documentation
- Removed some outdated storage/etc examples
- Expanded `APIClient.guilds_roles_create` to handle more attributes
- Bumped various requirement versions
## v0.0.11
### Additions
- Added support for Guild audit logs, exposed via `Guild.get_audit_log_entries`, `Guild.audit_log` and `Guild.audit_log_iter`. For more information see the `AuditLogEntry` model
- Added built-in Flask HTTP server which can be enabled via `http_enabled` and configured via `http_host`/`http_port` config options. The server allows plugins to define routes which can be called externally.
- Added support for capturing the raw responses returned from API requests via the `APIClient.capture` contextmanager
- Added support for NSFW channels via `Channel.nsfw` and `Channel.is_nsfw`
- Added initial support for channel categories via `Channel.parent_id` and `Channel.parent`
- Added various setters for updating Channel properties, e.g. `Channel.set_topic`
- Added support for audit log reasons, accessible through passing `reason` to various methods
- Added `disco.util.snowflake.from_timestamp_ms`
- Added support for `on_complete` callback within DCADOpusEncoderPlayable
- **BREAKING** Added new custom queue types `BaseQueue`/`PlayableQueue` for use w/ `Player`.
- `queue` can be passed when creating a `Player`, should inherit from BaseQueue
- Users who previously utilized the `put` method of the old `Player.queue` must move to using `Player.queue.append`, or providing a custom queue implementation.
- Added `Emoji.custom` property
### Fixes
- Fixed GuildRoleCreate missing guild\_id, resulting in incorrect state
- Fixed SimpleLimiter behaving incorrectly (causing GW socket to be ratelimited in some cases)
- Fixed the shortest possible match for a single command being an empty string
- Fixed group matching being overly greedy, which allowed for extra characters to be allowed at the end of a group match
- Fixed errors thrown when not enabling manhole via cli
- Fixed various warnings emitted due to useage of StopIteration
- Fixed warnings about missing voice libs when importing `disco.types.channel`
- Fixed `Bot.get_commands_for_message` returning None (instead of empty list) in some cases
### Etc
- Greatly imrpoved the performance of `HashMap`
- **BREAKING** Increased the weight of group matches over command argument matches, and limited the number of commands executed per message to one.
- Reuse a buffer in voice code to slightly improve performance
## v0.0.11-rc.8
### Additions
- Added support for capturing the raw responses returned from the API via `APIClient.capture` contextmanager
- Added various pieces of documentation
### Fixes
- Fixed Python 3 errors and Python 2 deprecation warnings for CommandError using `.message` attribute
### ETC
- Grealty improved the performance of the custom HashMap
- Moved tests around and added pytest as the testing framework of choice
## v0.0.11-rc.7
### Additions
- Added support for new NSFW attribute of channels
- `Channel.nsfw`
- `Channel.set_nsfw`
- `Channel.is_nsfw` behaves correctly, checking both the deprecated `nsfw-` prefix and the new attribute
- Added support for `on_complete` callback within DCADOpusEncoderPlayable
- **BREAKING** Added new custom queue types `BaseQueue`/`PlayableQueue` for use w/ `Player`.
- `queue` can be passed when creating a `Player`, should inherit from BaseQueue
- Users who previously utilized the `put` method of the old `Player.queue` must move to using `Player.queue.append`, or providing a custom queue implementation.
### Fixes
- Fixed bug within SimpleLimiter which would cause all events after a quiescent period to be immedietly dispatched. This would cause gateway disconnects w/ RATE\_LIMITED on clients with many Guilds and member sync enabled.
### ETC
- Improved log messages within GatewayClient
- Log voice endpoint within VoiceClient

3
disco/api/client.py

@ -6,7 +6,6 @@ from contextlib import contextmanager
from gevent.local import local
from six.moves.urllib.parse import quote
from holster.enum import EnumAttr
from disco.api.http import Routes, HTTPClient, to_bytes
from disco.util.logging import LoggingClass
from disco.util.sanitize import S
@ -308,7 +307,7 @@ class APIClient(LoggingClass):
payload = {
'name': name,
'type': channel_type.value if isinstance(channel_type, EnumAttr) else channel_type,
'type': channel_type,
'permission_overwrites': [i.to_dict() for i in permission_overwrites],
'parent_id': parent_id,
}

23
disco/api/http.py

@ -4,21 +4,18 @@ import gevent
import six
import sys
from holster.enum import Enum
from disco import VERSION as disco_version
from requests import __version__ as requests_version
from disco.util.logging import LoggingClass
from disco.api.ratelimit import RateLimiter
# Enum of all HTTP methods used
HTTPMethod = Enum(
GET='GET',
POST='POST',
PUT='PUT',
PATCH='PATCH',
DELETE='DELETE',
)
class HTTPMethod(object):
GET = 'GET'
POST = 'POST'
PUT = 'PUT'
PATCH = 'PATCH'
DELETE = 'DELETE'
def to_bytes(obj):
@ -257,7 +254,7 @@ class HTTPClient(LoggingClass):
# Build the bucket URL
args = {k: to_bytes(v) for k, v in six.iteritems(args)}
filtered = {k: (v if k in ('guild', 'channel') else '') for k, v in six.iteritems(args)}
bucket = (route[0].value, route[1].format(**filtered))
bucket = (route[0], route[1].format(**filtered))
response = APIResponse()
@ -268,8 +265,8 @@ class HTTPClient(LoggingClass):
# Make the actual request
url = self.BASE_URL + route[1].format(**args)
self.log.info('%s %s (%s)', route[0].value, url, kwargs.get('params'))
r = self.session.request(route[0].value, url, **kwargs)
self.log.info('%s %s (%s)', route[0], url, kwargs.get('params'))
r = self.session.request(route[0], url, **kwargs)
if self.after_request:
response.response = r

2
disco/bot/bot.py

@ -6,7 +6,6 @@ import inspect
import importlib
from six.moves import reload_module
from holster.threadlocal import ThreadLocal
from gevent.pywsgi import WSGIServer
from disco.types.guild import GuildMember
@ -16,6 +15,7 @@ from disco.bot.storage import Storage
from disco.util.config import Config
from disco.util.logging import LoggingClass
from disco.util.serializer import Serializer
from disco.util.threadlocal import ThreadLocal
class BotConfig(Config):

16
disco/bot/command.py

@ -1,8 +1,6 @@
import re
import argparse
from holster.enum import Enum
from six import integer_types
from disco.bot.parser import ArgumentSet, ArgumentError
@ -16,13 +14,13 @@ USER_MENTION_RE = re.compile('<@!?([0-9]+)>')
ROLE_MENTION_RE = re.compile('<@&([0-9]+)>')
CHANNEL_MENTION_RE = re.compile('<#([0-9]+)>')
CommandLevels = Enum(
DEFAULT=0,
TRUSTED=10,
MOD=50,
ADMIN=100,
OWNER=500,
)
class CommandLevels(object):
DEFAULT = 0
TRUSTED = 10
MOD = 50
ADMIN = 100
OWNER = 500
class PluginArgumentParser(argparse.ArgumentParser):

2
disco/bot/plugin.py

@ -7,8 +7,8 @@ import warnings
import functools
from gevent.event import AsyncResult
from holster.emitter import Priority
from disco.util.emitter import Priority
from disco.util.logging import LoggingClass
from disco.bot.command import Command, CommandError

3
disco/client.py

@ -1,14 +1,13 @@
import time
import gevent
from holster.emitter import Emitter
from disco.state import State, StateConfig
from disco.api.client import APIClient
from disco.gateway.client import GatewayClient
from disco.gateway.packets import OPCode
from disco.types.user import Status, Game
from disco.util.config import Config
from disco.util.emitter import Emitter
from disco.util.logging import LoggingClass
from disco.util.backdoor import DiscoBackdoorServer

4
disco/gateway/client.py

@ -78,7 +78,7 @@ class GatewayClient(LoggingClass):
self.log.debug('GatewayClient.send %s', op)
self.packets.emit((SEND, op), data)
self.ws.send(self.encoder.encode({
'op': op.value,
'op': op,
'd': data,
}), self.encoder.OPCODE)
@ -188,7 +188,7 @@ class GatewayClient(LoggingClass):
self.seq = data['s']
# Emit packet
self.packets.emit((RECV, OPCode[data['op']]), data)
self.packets.emit((RECV, data['op']), data)
def on_error(self, error):
if isinstance(error, KeyboardInterrupt):

4
disco/gateway/encoding/base.py

@ -1,9 +1,7 @@
from websocket import ABNF
from holster.interface import Interface
class BaseEncoder(Interface):
class BaseEncoder(object):
TYPE = None
OPCODE = ABNF.OPCODE_TEXT

21
disco/gateway/ipc.py

@ -3,8 +3,6 @@ import gevent
import string
import weakref
from holster.enum import Enum
from disco.util.logging import LoggingClass
from disco.util.serializer import dump_function, load_function
@ -13,12 +11,11 @@ def get_random_str(size):
return ''.join([random.choice(string.printable) for _ in range(size)])
IPCMessageType = Enum(
'CALL_FUNC',
'GET_ATTR',
'EXECUTE',
'RESPONSE',
)
class IPCMessageType(object):
CALL_FUNC = 1
GET_ATTR = 2
EXECUTE = 3
RESPONSE = 4
class GIPCProxy(LoggingClass):
@ -37,7 +34,7 @@ class GIPCProxy(LoggingClass):
return base
def send(self, typ, data):
self.pipe.put((typ.value, data))
self.pipe.put((typ, data))
def handle(self, mtype, data):
if mtype == IPCMessageType.CALL_FUNC:
@ -75,17 +72,17 @@ class GIPCProxy(LoggingClass):
nonce = get_random_str(32)
raw = dump_function(func)
self.results[nonce] = result = gevent.event.AsyncResult()
self.pipe.put((IPCMessageType.EXECUTE.value, (nonce, raw)))
self.pipe.put((IPCMessageType.EXECUTE, (nonce, raw)))
return result
def get(self, path):
nonce = get_random_str(32)
self.results[nonce] = result = gevent.event.AsyncResult()
self.pipe.put((IPCMessageType.GET_ATTR.value, (nonce, path)))
self.pipe.put((IPCMessageType.GET_ATTR, (nonce, path)))
return result
def call(self, path, *args, **kwargs):
nonce = get_random_str(32)
self.results[nonce] = result = gevent.event.AsyncResult()
self.pipe.put((IPCMessageType.CALL_FUNC.value, (nonce, path, args, kwargs)))
self.pipe.put((IPCMessageType.CALL_FUNC, (nonce, path, args, kwargs)))
return result

32
disco/gateway/packets.py

@ -1,20 +1,18 @@
from holster.enum import Enum
SEND = 1
RECV = 2
OPCode = Enum(
DISPATCH=0,
HEARTBEAT=1,
IDENTIFY=2,
STATUS_UPDATE=3,
VOICE_STATE_UPDATE=4,
VOICE_SERVER_PING=5,
RESUME=6,
RECONNECT=7,
REQUEST_GUILD_MEMBERS=8,
INVALID_SESSION=9,
HELLO=10,
HEARTBEAT_ACK=11,
GUILD_SYNC=12,
)
class OPCode(object):
DISPATCH = 0
HEARTBEAT = 1
IDENTIFY = 2
STATUS_UPDATE = 3
VOICE_STATE_UPDATE = 4
VOICE_SERVER_PING = 5
RESUME = 6
RECONNECT = 7
REQUEST_GUILD_MEMBERS = 8
INVALID_SESSION = 9
HELLO = 10
HEARTBEAT_ACK = 11
GUILD_SYNC = 12

4
disco/state.py

@ -3,12 +3,12 @@ import weakref
from collections import deque, namedtuple
from gevent.event import Event
from holster.emitter import Priority
from disco.types.base import UNSET
from disco.util.config import Config
from disco.util.string import underscore
from disco.util.hashmap import HashMap, DefaultHashMap
from disco.util.emitter import Priority
from disco.voice.client import VoiceState
@ -139,7 +139,7 @@ class State(object):
for event in self.EVENTS:
func = 'on_' + underscore(event)
self.listeners.append(self.client.events.on(event, getattr(self, func), priority=Priority.BEFORE))
self.listeners.append(self.client.events.on(event, getattr(self, func), priority=Priority.AFTER))
def fill_messages(self, channel):
for message in reversed(next(channel.messages_iter(bulk=True))):

37
disco/types/base.py

@ -3,7 +3,6 @@ import gevent
import inspect
import functools
from holster.enum import BaseEnumMeta, EnumAttr
from datetime import datetime as real_datetime
from disco.util.chains import Chainable
@ -109,10 +108,10 @@ class Field(object):
def type_to_deserializer(typ):
if isinstance(typ, Field) or inspect.isclass(typ) and issubclass(typ, Model):
return typ
elif isinstance(typ, BaseEnumMeta):
def _f(raw, client, **kwargs):
return typ.get(raw)
return _f
# elif isinstance(typ, BaseEnumMeta):
# def _f(raw, client, **kwargs):
# return typ.get(raw)
# return _f
elif typ is None:
def _f(*args, **kwargs):
return None
@ -123,9 +122,7 @@ class Field(object):
@staticmethod
def serialize(value, inst=None):
if isinstance(value, EnumAttr):
return value.value
elif isinstance(value, Model):
if isinstance(value, Model):
return value.to_dict(ignore=(inst.ignore_dump if inst else []))
else:
if inst and inst.cast:
@ -194,11 +191,29 @@ def snowflake(data):
return int(data) if data else None
def _enum_attrs(enum):
for k, v in six.iteritems(enum.__dict__):
if not isinstance(k, six.string_types):
continue
if k.startswith('_') or not k.isupper():
continue
yield k, v
def enum(typ):
def _f(data):
if isinstance(data, str):
data = data.lower()
return typ.get(data) if data is not None else None
if data is None:
return None
for k, v in _enum_attrs(typ):
if isinstance(data, six.string_types) and k == data.upper():
return v
elif k == data or v == data:
return v
return None
return _f

28
disco/types/channel.py

@ -2,7 +2,6 @@ import re
import six
from six.moves import map
from holster.enum import Enum
from disco.util.snowflake import to_snowflake
from disco.util.functional import one_or_many, chunks
@ -14,19 +13,18 @@ from disco.types.permissions import Permissions, Permissible, PermissionValue
NSFW_RE = re.compile('^nsfw(-|$)')
ChannelType = Enum(
GUILD_TEXT=0,
DM=1,
GUILD_VOICE=2,
GROUP_DM=3,
GUILD_CATEGORY=4,
GUILD_NEWS=5,
)
class ChannelType(object):
GUILD_TEXT = 0
DM = 1
GUILD_VOICE = 2
GROUP_DM = 3
GUILD_CATEGORY = 4
GUILD_NEWS = 5
PermissionOverwriteType = Enum(
ROLE='role',
MEMBER='member',
)
class PermissionOverwriteType(object):
ROLE = 'role'
MEMBER = 'member'
class ChannelSubType(SlottedModel):
@ -528,7 +526,9 @@ class MessageIterator(object):
chunk_size : int
The number of messages to request per API call.
"""
Direction = Enum('UP', 'DOWN')
class Direction(object):
UP = 1
DOWN = 2
def __init__(self, client, channel, direction=Direction.UP, bulk=False, before=None, after=None, chunk_size=100):
self.client = client

88
disco/types/guild.py

@ -1,8 +1,6 @@
import six
import warnings
from holster.enum import Enum
from disco.api.http import APIException
from disco.util.paginator import Paginator
from disco.util.snowflake import to_snowflake
@ -17,24 +15,23 @@ from disco.types.message import Emoji
from disco.types.permissions import PermissionValue, Permissions, Permissible
VerificationLevel = Enum(
NONE=0,
LOW=1,
MEDIUM=2,
HIGH=3,
EXTREME=4,
)
class VerificationLevel(object):
NONE = 0
LOW = 1
MEDIUM = 2
HIGH = 3
EXTREME = 4
ExplicitContentFilterLevel = Enum(
NONE=0,
WITHOUT_ROLES=1,
ALL=2,
)
DefaultMessageNotificationsLevel = Enum(
ALL_MESSAGES=0,
ONLY_MENTIONS=1,
)
class ExplicitContentFilterLevel(object):
NONE = 0
WITHOUT_ROLES = 1
ALL = 2
class DefaultMessageNotificationsLevel(object):
ALL_MESSAGES = 0
ONLY_MENTIONS = 1
class GuildEmoji(Emoji):
@ -551,34 +548,33 @@ class Guild(SlottedModel, Permissible):
return self.client.api.guilds_auditlogs_list(self.id, *args, **kwargs)
AuditLogActionTypes = Enum(
GUILD_UPDATE=1,
CHANNEL_CREATE=10,
CHANNEL_UPDATE=11,
CHANNEL_DELETE=12,
CHANNEL_OVERWRITE_CREATE=13,
CHANNEL_OVERWRITE_UPDATE=14,
CHANNEL_OVERWRITE_DELETE=15,
MEMBER_KICK=20,
MEMBER_PRUNE=21,
MEMBER_BAN_ADD=22,
MEMBER_BAN_REMOVE=23,
MEMBER_UPDATE=24,
MEMBER_ROLE_UPDATE=25,
ROLE_CREATE=30,
ROLE_UPDATE=31,
ROLE_DELETE=32,
INVITE_CREATE=40,
INVITE_UPDATE=41,
INVITE_DELETE=42,
WEBHOOK_CREATE=50,
WEBHOOK_UPDATE=51,
WEBHOOK_DELETE=52,
EMOJI_CREATE=60,
EMOJI_UPDATE=61,
EMOJI_DELETE=62,
MESSAGE_DELETE=72,
)
class AuditLogActionTypes(object):
GUILD_UPDATE = 1
CHANNEL_CREATE = 10
CHANNEL_UPDATE = 11
CHANNEL_DELETE = 12
CHANNEL_OVERWRITE_CREATE = 13
CHANNEL_OVERWRITE_UPDATE = 14
CHANNEL_OVERWRITE_DELETE = 15
MEMBER_KICK = 20
MEMBER_PRUNE = 21
MEMBER_BAN_ADD = 22
MEMBER_BAN_REMOVE = 23
MEMBER_UPDATE = 24
MEMBER_ROLE_UPDATE = 25
ROLE_CREATE = 30
ROLE_UPDATE = 31
ROLE_DELETE = 32
INVITE_CREATE = 40
INVITE_UPDATE = 41
INVITE_DELETE = 42
WEBHOOK_CREATE = 50
WEBHOOK_UPDATE = 51
WEBHOOK_DELETE = 52
EMOJI_CREATE = 60
EMOJI_UPDATE = 61
EMOJI_DELETE = 62
MESSAGE_DELETE = 72
GUILD_ACTIONS = (

21
disco/types/message.py

@ -4,8 +4,6 @@ import warnings
import functools
import unicodedata
from holster.enum import Enum
from disco.types.base import (
SlottedModel, Field, ListField, AutoDictField, snowflake, text,
datetime, enum, cached_property,
@ -15,16 +13,15 @@ from disco.util.snowflake import to_snowflake
from disco.types.user import User
MessageType = Enum(
DEFAULT=0,
RECIPIENT_ADD=1,
RECIPIENT_REMOVE=2,
CALL=3,
CHANNEL_NAME_CHANGE=4,
CHANNEL_ICON_CHANGE=5,
PINS_ADD=6,
GUILD_MEMBER_JOIN=7,
)
class MessageType(object):
DEFAULT = 0
RECIPIENT_ADD = 1
RECIPIENT_REMOVE = 2
CALL = 3
CHANNEL_NAME_CHANGE = 4
CHANNEL_ICON_CHANGE = 5
PINS_ADD = 6
GUILD_MEMBER_JOIN = 7
class Emoji(SlottedModel):

79
disco/types/permissions.py

@ -1,40 +1,45 @@
from holster.enum import Enum, EnumAttr
Permissions = Enum(
CREATE_INSTANT_INVITE=1 << 0,
KICK_MEMBERS=1 << 1,
BAN_MEMBERS=1 << 2,
ADMINISTRATOR=1 << 3,
MANAGE_CHANNELS=1 << 4,
MANAGE_GUILD=1 << 5,
READ_MESSAGES=1 << 10,
SEND_MESSAGES=1 << 11,
SEND_TSS_MESSAGES=1 << 12,
MANAGE_MESSAGES=1 << 13,
EMBED_LINKS=1 << 14,
ATTACH_FILES=1 << 15,
READ_MESSAGE_HISTORY=1 << 16,
MENTION_EVERYONE=1 << 17,
USE_EXTERNAL_EMOJIS=1 << 18,
CONNECT=1 << 20,
SPEAK=1 << 21,
MUTE_MEMBERS=1 << 22,
DEAFEN_MEMBERS=1 << 23,
MOVE_MEMBERS=1 << 24,
USE_VAD=1 << 25,
CHANGE_NICKNAME=1 << 26,
MANAGE_NICKNAMES=1 << 27,
MANAGE_ROLES=1 << 28,
MANAGE_WEBHOOKS=1 << 29,
MANAGE_EMOJIS=1 << 30,
)
import six
class Permissions(object):
CREATE_INSTANT_INVITE = 1 << 0
KICK_MEMBERS = 1 << 1
BAN_MEMBERS = 1 << 2
ADMINISTRATOR = 1 << 3
MANAGE_CHANNELS = 1 << 4
MANAGE_GUILD = 1 << 5
READ_MESSAGES = 1 << 10
SEND_MESSAGES = 1 << 11
SEND_TSS_MESSAGES = 1 << 12
MANAGE_MESSAGES = 1 << 13
EMBED_LINKS = 1 << 14
ATTACH_FILES = 1 << 15
READ_MESSAGE_HISTORY = 1 << 16
MENTION_EVERYONE = 1 << 17
USE_EXTERNAL_EMOJIS = 1 << 18
CONNECT = 1 << 20
SPEAK = 1 << 21
MUTE_MEMBERS = 1 << 22
DEAFEN_MEMBERS = 1 << 23
MOVE_MEMBERS = 1 << 24
USE_VAD = 1 << 25
CHANGE_NICKNAME = 1 << 26
MANAGE_NICKNAMES = 1 << 27
MANAGE_ROLES = 1 << 28
MANAGE_WEBHOOKS = 1 << 29
MANAGE_EMOJIS = 1 << 30
@classmethod
def keys(cls):
for k, v in six.iteritems(cls.__dict__):
yield k
class PermissionValue(object):
__slots__ = ['value']
def __init__(self, value=0):
if isinstance(value, EnumAttr) or isinstance(value, PermissionValue):
if isinstance(value, PermissionValue):
value = value.value
self.value = value
@ -45,8 +50,6 @@ class PermissionValue(object):
return True
for perm in perms:
if isinstance(perm, EnumAttr):
perm = perm.value
if not (self.value & perm) == perm:
return False
return True
@ -56,8 +59,6 @@ class PermissionValue(object):
self.value |= other.value
elif isinstance(other, int):
self.value |= other
elif isinstance(other, EnumAttr):
setattr(self, other.name, True)
else:
raise TypeError('Cannot PermissionValue.add from type {}'.format(type(other)))
return self
@ -67,8 +68,6 @@ class PermissionValue(object):
self.value &= ~other.value
elif isinstance(other, int):
self.value &= ~other
elif isinstance(other, EnumAttr):
setattr(self, other.name, False)
else:
raise TypeError('Cannot PermissionValue.sub from type {}'.format(type(other)))
return self
@ -80,13 +79,13 @@ class PermissionValue(object):
return self.sub(other)
def __getattribute__(self, name):
if name in Permissions.keys_:
if name in list(Permissions.keys()):
return (self.value & Permissions[name].value) == Permissions[name].value
else:
return object.__getattribute__(self, name)
def __setattr__(self, name, value):
if name not in Permissions.keys_:
if name not in list(Permissions.keys()):
return super(PermissionValue, self).__setattr__(name, value)
if value:
@ -99,7 +98,7 @@ class PermissionValue(object):
def to_dict(self):
return {
k: getattr(self, k) for k in Permissions.keys_
k: getattr(self, k) for k in list(Permissions.keys())
}
@classmethod

51
disco/types/user.py

@ -1,14 +1,14 @@
from holster.enum import Enum
from disco.types.base import SlottedModel, Field, snowflake, text, with_equality, with_hash, enum
from disco.types.base import SlottedModel, Field, snowflake, text, with_equality, with_hash
DefaultAvatars = Enum(
BLURPLE=0,
GREY=1,
GREEN=2,
ORANGE=3,
RED=4,
)
class DefaultAvatars(object):
BLURPLE = 0
GREY = 1
GREEN = 2
ORANGE = 3
RED = 4
ALL = [BLURPLE, GREY, GREEN, ORANGE, RED]
class User(SlottedModel, with_equality('id'), with_hash('id')):
@ -24,7 +24,7 @@ class User(SlottedModel, with_equality('id'), with_hash('id')):
def get_avatar_url(self, fmt=None, size=1024):
if not self.avatar:
return 'https://cdn.discordapp.com/embed/avatars/{}.png'.format(self.default_avatar.value)
return 'https://cdn.discordapp.com/embed/avatars/{}.png'.format(self.default_avatar)
if fmt is not None:
return 'https://cdn.discordapp.com/avatars/{}/{}.{}?size={}'.format(self.id, self.avatar, fmt, size)
if self.avatar.startswith('a_'):
@ -34,7 +34,7 @@ class User(SlottedModel, with_equality('id'), with_hash('id')):
@property
def default_avatar(self):
return DefaultAvatars[int(self.discriminator) % len(DefaultAvatars.attrs)]
return DefaultAvatars.ALL[int(self.discriminator) % len(DefaultAvatars.ALL)]
@property
def avatar_url(self):
@ -54,24 +54,23 @@ class User(SlottedModel, with_equality('id'), with_hash('id')):
return u'<User {} ({})>'.format(self.id, self)
GameType = Enum(
DEFAULT=0,
STREAMING=1,
LISTENING=2,
WATCHING=3,
)
class GameType(object):
DEFAULT = 0
STREAMING = 1
LISTENING = 2
WATCHING = 3
Status = Enum(
'ONLINE',
'IDLE',
'DND',
'INVISIBLE',
'OFFLINE',
)
class Status(object):
ONLINE = 'ONLINE'
IDLE = 'IDLE'
DND = 'DND'
INVISIBLE = 'INVISIBLE'
OFFLINE = 'OFFLINE'
class Game(SlottedModel):
type = Field(GameType)
type = Field(enum(GameType))
name = Field(text)
url = Field(text)
@ -79,4 +78,4 @@ class Game(SlottedModel):
class Presence(SlottedModel):
user = Field(User, alias='user', ignore_dump=['presence'])
game = Field(Game)
status = Field(Status)
status = Field(enum(Status))

162
disco/util/emitter.py

@ -0,0 +1,162 @@
import gevent
from collections import defaultdict
from gevent.event import AsyncResult
from gevent.queue import Queue, Full
class Priority(object):
# BEFORE is the most dangerous priority level. Every event that flows through
# the given emitter instance will be dispatched _sequentially_ to all BEFORE
# handlers. Until these before handlers complete execution, no other event
# will be allowed to continue. Any exceptions raised will be ignored.
BEFORE = 1
# AFTER has the same behavior as before with regards to dispatching events,
# with the one difference being it executes after all the BEFORE listeners.
AFTER = 2
# SEQUENTIAL guarentees that all events your handler recieves will be ordered
# when looked at in isolation. SEQUENTIAL handlers will not block other handlers,
# but do use a queue internally and thus can fall behind.
SEQUENTIAL = 3
# NONE provides no guarentees around the ordering or execution of events, sans
# that BEFORE handlers will always complete before any NONE handlers are called.
NONE = 4
ALL = {BEFORE, AFTER, SEQUENTIAL, NONE}
class Event(object):
def __init__(self, parent, data):
self.parent = parent
self.data = data
def __getattr__(self, name):
if hasattr(self.data, name):
return getattr(self.data, name)
raise AttributeError
class EmitterSubscription(object):
def __init__(self, events, callback, priority=Priority.NONE, conditional=None, metadata=None, max_queue_size=8096):
self.events = events
self.callback = callback
self.priority = priority
self.conditional = conditional
self.metadata = metadata or {}
self.max_queue_size = max_queue_size
self._emitter = None
self._queue = None
self._queue_greenlet = None
if priority == Priority.SEQUENTIAL:
self._queue_greenlet = gevent.spawn(self._queue_handler)
def __del__(self):
if self._emitter:
self.detach()
if self._queue_greenlet:
self._queue_greenlet.kill()
def __call__(self, *args, **kwargs):
if self._queue is not None:
try:
self._queue.put_nowait((args, kwargs))
except Full:
# TODO: warning
pass
return
if callable(self.conditional):
if not self.conditional(*args, **kwargs):
return
return self.callback(*args, **kwargs)
def _queue_handler(self):
self._queue = Queue(self.max_queue_size)
while True:
args, kwargs = self._queue.get()
try:
self.callback(*args, **kwargs)
except Exception:
# TODO: warning
pass
def attach(self, emitter):
self._emitter = emitter
for event in self.events:
self._emitter.event_handlers[self.priority][event].append(self)
return self
def detach(self, emitter=None):
emitter = emitter or self._emitter
for event in self.events:
if self in emitter.event_handlers[self.priority][event]:
emitter.event_handlers[self.priority][event].remove(self)
def remove(self, emitter=None):
self.detach(emitter)
class Emitter(object):
def __init__(self):
self.event_handlers = {
k: defaultdict(list) for k in Priority.ALL
}
def emit(self, name, *args, **kwargs):
# First execute all BEFORE handlers sequentially
for listener in self.event_handlers[Priority.BEFORE].get(name, []):
try:
listener(*args, **kwargs)
except Exception:
pass
# Next execute all AFTER handlers sequentially
for listener in self.event_handlers[Priority.AFTER].get(name, []):
try:
listener(*args, **kwargs)
except Exception:
pass
# Next enqueue all sequential handlers. This just puts stuff into a queue
# without blocking, so we don't have to worry too much
for listener in self.event_handlers[Priority.SEQUENTIAL].get(name, []):
listener(*args, **kwargs)
# Finally just spawn for everything else
for listener in self.event_handlers[Priority.NONE].get(name, []):
gevent.spawn(listener, *args, **kwargs)
def on(self, *args, **kwargs):
return EmitterSubscription(args[:-1], args[-1], **kwargs).attach(self)
def once(self, *args, **kwargs):
result = AsyncResult()
li = None
def _f(e):
result.set(e)
li.detach()
li = self.on(*args + (_f, ))
return result.wait(kwargs.pop('timeout', None))
def wait(self, *args, **kwargs):
result = AsyncResult()
match = args[-1]
def _f(e):
if match(e):
result.set(e)
return result.wait(kwargs.pop('timeout', None))

27
disco/util/threadlocal.py

@ -0,0 +1,27 @@
import gevent
import weakref
class ThreadLocal(object):
___slots__ = ['storage']
def __init__(self):
self.storage = weakref.WeakKeyDictionary()
def get(self):
if gevent.getcurrent() not in self.storage:
self.storage[gevent.getcurrent()] = {}
return self.storage[gevent.getcurrent()]
def drop(self):
if gevent.getcurrent() in self.storage:
del self.storage[gevent.getcurrent()]
def __contains__(self, key):
return key in self.get()
def __getitem__(self, item):
return self.get()[item]
def __setitem__(self, item, value):
self.get()[item] = value

3
disco/util/websocket.py

@ -3,8 +3,7 @@ from __future__ import absolute_import
import websocket
import six
from holster.emitter import Emitter
from disco.util.emitter import Emitter
from disco.util.logging import LoggingClass

41
disco/voice/client.py

@ -5,35 +5,34 @@ import time
from collections import namedtuple
from holster.enum import Enum
from holster.emitter import Emitter
from disco.gateway.encoding.json import JSONEncoder
from disco.util.websocket import Websocket
from disco.util.logging import LoggingClass
from disco.util.emitter import Emitter
from disco.gateway.packets import OPCode
from disco.types.base import cached_property
from disco.voice.packets import VoiceOPCode
from disco.voice.udp import AudioCodecs, RTPPayloadTypes, UDPVoiceClient
SpeakingFlags = Enum(
NONE=0,
VOICE=1 << 0,
SOUNDSHARE=1 << 1,
PRIORITY=1 << 2,
)
VoiceState = Enum(
DISCONNECTED=0,
RECONNECTING=1,
AWAITING_ENDPOINT=2,
AUTHENTICATING=3,
AUTHENTICATED=4,
CONNECTING=5,
CONNECTED=6,
VOICE_CONNECTING=7,
VOICE_CONNECTED=8,
)
class SpeakingFlags(object):
NONE = 0
VOICE = 1 << 0
SOUNDSHARE = 1 << 1
PRIORITY = 1 << 2
class VoiceState(object):
DISCONNECTED = 0
RECONNECTING = 1
AWAITING_ENDPOINT = 2
AUTHENTICATING = 3
AUTHENTICATED = 4
CONNECTING = 5
CONNECTED = 6
VOICE_CONNECTING = 7
VOICE_CONNECTED = 8
VoiceSpeaking = namedtuple('VoiceSpeaking', [
'client',

30
disco/voice/opus.py

@ -4,8 +4,6 @@ import array
import ctypes
import ctypes.util
from holster.enum import Enum
from disco.util.logging import LoggingClass
@ -59,19 +57,17 @@ class BaseOpus(LoggingClass):
return ctypes.util.find_library('opus')
Application = Enum(
AUDIO=2049,
VOIP=2048,
LOWDELAY=2051,
)
class Application(object):
AUDIO = 2049
VOIP = 2048
LOWDELAY = 2051
Control = Enum(
SET_BITRATE=4002,
SET_BANDWIDTH=4008,
SET_FEC=4012,
SET_PLP=4014,
)
class Control(object):
SET_BITRATE = 4002
SET_BANDWIDTH = 4008
SET_FEC = 4012
SET_PLP = 4014
class OpusEncoder(BaseOpus):
@ -102,26 +98,26 @@ class OpusEncoder(BaseOpus):
def set_bitrate(self, kbps):
kbps = min(128, max(16, int(kbps)))
ret = self.opus_encoder_ctl(self.inst, int(Control.SET_BITRATE), kbps * 1024)
ret = self.opus_encoder_ctl(self.inst, Control.SET_BITRATE, kbps * 1024)
if ret < 0:
raise Exception('Failed to set bitrate to {}: {}'.format(kbps, ret))
def set_fec(self, value):
ret = self.opus_encoder_ctl(self.inst, int(Control.SET_FEC), int(value))
ret = self.opus_encoder_ctl(self.inst, Control.SET_FEC, int(value))
if ret < 0:
raise Exception('Failed to set FEC to {}: {}'.format(value, ret))
def set_expected_packet_loss_percent(self, perc):
ret = self.opus_encoder_ctl(self.inst, int(Control.SET_PLP), min(100, max(0, int(perc * 100))))
ret = self.opus_encoder_ctl(self.inst, Control.SET_PLP, min(100, max(0, int(perc * 100))))
if ret < 0:
raise Exception('Failed to set PLP to {}: {}'.format(perc, ret))
def create(self):
ret = ctypes.c_int()
result = self.opus_encoder_create(self.sampling_rate, self.channels, self.application.value, ctypes.byref(ret))
result = self.opus_encoder_create(self.sampling_rate, self.channels, self.application, ctypes.byref(ret))
if ret.value != 0:
raise Exception('Failed to create opus encoder: {}'.format(ret.value))

31
disco/voice/packets.py

@ -1,17 +1,14 @@
from holster.enum import Enum
VoiceOPCode = Enum(
IDENTIFY=0,
SELECT_PROTOCOL=1,
READY=2,
HEARTBEAT=3,
SESSION_DESCRIPTION=4,
SPEAKING=5,
HEARTBEAT_ACK=6,
RESUME=7,
HELLO=8,
RESUMED=9,
CLIENT_CONNECT=12,
CLIENT_DISCONNECT=13,
CODECS=14,
)
class VoiceOPCode(object):
IDENTIFY = 0
SELECT_PROTOCOL = 1
READY = 2
HEARTBEAT = 3
SESSION_DESCRIPTION = 4
SPEAKING = 5
HEARTBEAT_ACK = 6
RESUME = 7
HELLO = 8
RESUMED = 9
CLIENT_CONNECT = 12
CLIENT_DISCONNECT = 13
CODECS = 14

17
disco/voice/player.py

@ -1,22 +1,19 @@
import time
import gevent
from holster.enum import Enum
from holster.emitter import Emitter
from disco.voice.client import VoiceState
from disco.voice.queue import PlayableQueue
from disco.util.emitter import Emitter
from disco.util.logging import LoggingClass
class Player(LoggingClass):
Events = Enum(
'START_PLAY',
'STOP_PLAY',
'PAUSE_PLAY',
'RESUME_PLAY',
'DISCONNECT',
)
class Events(object):
START_PLAY = 1
STOP_PLAY = 2
PAUSE_PLAY = 3
RESUME_PLAY = 4
DISCONNECT = 5
def __init__(self, client, queue=None):
super(Player, self).__init__()

48
disco/voice/udp.py

@ -9,23 +9,30 @@ try:
except ImportError:
print('WARNING: nacl is not installed, voice support is disabled')
from holster.enum import Enum
from disco.util.logging import LoggingClass
AudioCodecs = ('opus',)
RTPPayloadTypes = Enum(OPUS=0x78)
RTCPPayloadTypes = Enum(
SENDER_REPORT=200,
RECEIVER_REPORT=201,
SOURCE_DESCRIPTION=202,
BYE=203,
APP=204,
RTPFB=205,
PSFB=206,
)
class RTPPayloadTypes(object):
OPUS = 0x78
ALL = {OPUS}
class RTCPPayloadTypes(object):
SENDER_REPORT = 200
RECEIVER_REPORT = 201
SOURCE_DESCRIPTION = 202
BYE = 203
APP = 204
RTPFB = 205
PSFB = 206
ALL = {
SENDER_REPORT, RECEIVER_REPORT, SOURCE_DESCRIPTION, BYE, APP, RTPFB, PSFB,
}
MAX_UINT32 = 4294967295
MAX_SEQUENCE = 65535
@ -101,9 +108,9 @@ class UDPVoiceClient(LoggingClass):
if codec not in AudioCodecs:
raise Exception('Unsupported audio codec received, {}'.format(codec))
ptype = RTPPayloadTypes.get(codec)
self._rtp_audio_header[1] = ptype.value
self.log.debug('[%s] Set UDP\'s Audio Codec to %s, RTP payload type %s', self.vc, ptype.name, ptype.value)
ptype = getattr(RTPPayloadTypes, codec.upper())
self._rtp_audio_header[1] = ptype
self.log.debug('[%s] Set UDP\'s Audio Codec to %s, RTP payload type %s', self.vc, codec, ptype)
def increment_timestamp(self, by):
self.timestamp += by
@ -173,8 +180,7 @@ class UDPVoiceClient(LoggingClass):
first, second = struct.unpack_from('>BB', data)
payload_type = RTCPPayloadTypes.get(second)
if payload_type:
if second in RTCPPayloadTypes.ALL:
length, ssrc = struct.unpack_from('>HI', data, 2)
rtcp = RTCPHeader(
@ -197,7 +203,7 @@ class UDPVoiceClient(LoggingClass):
payload = RTCPData(
client=self.vc,
user_id=user_id,
payload_type=payload_type.name,
payload_type=second,
header=rtcp,
data=data[8:],
)
@ -223,10 +229,8 @@ class UDPVoiceClient(LoggingClass):
self.log.debug('[%s] [VoiceData] Received an invalid RTP packet version, %s', self.vc, rtp.version)
continue
payload_type = RTPPayloadTypes.get(rtp.payload_type)
# Unsupported payload type received
if not payload_type:
if rtp.payload_type not in RTPPayloadTypes.ALL:
self.log.debug('[%s] [VoiceData] Received unsupported payload type, %s', self.vc, rtp.payload_type)
continue
@ -296,7 +300,7 @@ class UDPVoiceClient(LoggingClass):
payload = VoiceData(
client=self.vc,
user_id=self.vc.audio_ssrcs.get(rtp.ssrc, None),
payload_type=payload_type.name,
payload_type=second,
rtp=rtp,
nonce=nonce,
data=data,

2
docs/bot_tutorial/building_block_listeners.md

@ -31,7 +31,7 @@ To see all the events you can subscribe too, checkout the [gateway events list](
Each listener that's registered comes with a priority. This priority describes how the builtin event emitter will distribute events to your listener. To set a priority you can simply pass the priority kwarg:
```py
from holster.emitter import Priority
from disco.util.emitter import Priority
@Plugin.listen('GuildMemberAdd', priority=Priority.BEFORE)
def on_guild_member_add(self, event):

1
requirements.txt

@ -1,5 +1,4 @@
gevent==1.3.7
holster==2.0.0
requests==2.20.1
six==1.11.0
websocket-client==0.44.0

2
tests/state.py

@ -1,6 +1,6 @@
from disco.state import State, StateConfig
from holster.emitter import Emitter
from disco.gateway.events import VoiceStateUpdate
from disco.util.emitter import Emitter
class MockClient(object):

10
tests/types/types.py

@ -3,7 +3,6 @@ from __future__ import print_function
import six
from unittest import TestCase
from holster.enum import Enum
from disco.types.base import Model, Field, enum, snowflake, ConversionError
@ -64,11 +63,10 @@ class TestModel(TestCase):
self.assertEqual(obj, {'a': {'d': 'wow'}, 'b': {'z': 'wtf'}, 'g': 'lmao'})
def test_model_field_enum(self):
en = Enum(
A=1,
B=2,
C=3
)
class en(object):
A = 1
B = 2
C = 3
class _M(Model):
field = Field(enum(en))

Loading…
Cancel
Save