19 changed files with 342 additions and 293 deletions
@ -1,8 +1,15 @@ |
|||||
|
|
||||
|
|
||||
def cached_property(f): |
def cached_property(f): |
||||
def deco(self, *args, **kwargs): |
def getf(self, *args, **kwargs): |
||||
if not hasattr(self, '__' + f.__name__): |
if not hasattr(self, '__' + f.__name__): |
||||
setattr(self, '__' + f.__name__, f(self, *args, **kwargs)) |
setattr(self, '__' + f.__name__, f(self, *args, **kwargs)) |
||||
return getattr(self, '__' + f.__name__) |
return getattr(self, '__' + f.__name__) |
||||
return property(deco) |
|
||||
|
def setf(self, value): |
||||
|
setattr(self, '__' + f.__name__, value) |
||||
|
|
||||
|
def delf(self): |
||||
|
setattr(self, '__' + f.__name__, None) |
||||
|
|
||||
|
return property(getf, setf, delf) |
||||
|
@ -0,0 +1,11 @@ |
|||||
|
from __future__ import absolute_import |
||||
|
|
||||
|
from json import dumps |
||||
|
|
||||
|
try: |
||||
|
from rapidjson import loads |
||||
|
except ImportError: |
||||
|
print '[WARNING] rapidjson not installed, falling back to default Python JSON parser' |
||||
|
from json import loads |
||||
|
|
||||
|
__all__ = ['dumps', 'loads'] |
@ -1,139 +0,0 @@ |
|||||
import inspect |
|
||||
|
|
||||
|
|
||||
class TypedClassException(Exception): |
|
||||
pass |
|
||||
|
|
||||
|
|
||||
def construct_typed_class(cls, data): |
|
||||
obj = cls() |
|
||||
load_typed_class(obj, data) |
|
||||
return obj |
|
||||
|
|
||||
|
|
||||
def get_field_and_alias(field): |
|
||||
if isinstance(field, tuple): |
|
||||
return field |
|
||||
else: |
|
||||
return field, field |
|
||||
|
|
||||
|
|
||||
def get_optional(typ): |
|
||||
if isinstance(typ, tuple) and len(typ) == 1: |
|
||||
return True, typ[0] |
|
||||
return False, typ |
|
||||
|
|
||||
|
|
||||
def cast(typ, value): |
|
||||
valid = True |
|
||||
|
|
||||
# TODO: better exceptions |
|
||||
if isinstance(typ, list): |
|
||||
if typ: |
|
||||
typ = typ[0] |
|
||||
value = map(typ, value) |
|
||||
else: |
|
||||
list(value) |
|
||||
elif isinstance(typ, dict): |
|
||||
if typ: |
|
||||
ktyp, vtyp = typ.items()[0] |
|
||||
value = {ktyp(k): vtyp(v) for k, v in typ.items()} |
|
||||
else: |
|
||||
dict(value) |
|
||||
elif isinstance(typ, set): |
|
||||
if typ: |
|
||||
typ = list(typ)[0] |
|
||||
value = set(map(typ, value)) |
|
||||
else: |
|
||||
set(value) |
|
||||
elif isinstance(typ, str): |
|
||||
valid = False |
|
||||
elif not isinstance(value, typ): |
|
||||
value = typ(value) |
|
||||
|
|
||||
return valid, value |
|
||||
|
|
||||
|
|
||||
def load_typed_class(obj, params, data): |
|
||||
print obj, params, data |
|
||||
for field, typ in params.items(): |
|
||||
field, alias = get_field_and_alias(field) |
|
||||
|
|
||||
# Skipped field |
|
||||
if typ is None: |
|
||||
continue |
|
||||
|
|
||||
optional, typ = get_optional(typ) |
|
||||
if field not in data and not optional: |
|
||||
raise TypedClassException('Missing value for attribute `{}`'.format(field)) |
|
||||
|
|
||||
value = data[field] |
|
||||
|
|
||||
print field, alias, value, typ |
|
||||
if value is None: |
|
||||
if not optional: |
|
||||
raise TypedClassException('Non-optional attribute `{}` cannot take None'.format(field)) |
|
||||
else: |
|
||||
valid, value = cast(typ, value) |
|
||||
if not valid: |
|
||||
continue |
|
||||
|
|
||||
setattr(obj, alias, value) |
|
||||
|
|
||||
|
|
||||
def dump_typed_class(obj, params): |
|
||||
data = {} |
|
||||
|
|
||||
for field, typ in params.items(): |
|
||||
field, alias = get_field_and_alias(field) |
|
||||
|
|
||||
value = getattr(obj, alias, None) |
|
||||
|
|
||||
if typ is None: |
|
||||
data[field] = typ |
|
||||
continue |
|
||||
|
|
||||
optional, typ = get_optional(typ) |
|
||||
if not value and not optional: |
|
||||
raise TypedClassException('Missing value for attribute `{}`'.format(field)) |
|
||||
|
|
||||
_, value = cast(typ, value) |
|
||||
data[field] = value |
|
||||
|
|
||||
return data |
|
||||
|
|
||||
|
|
||||
def get_params(obj): |
|
||||
assert(issubclass(obj.__class__, TypedClass)) |
|
||||
|
|
||||
if not hasattr(obj.__class__, '_cached_oop_params'): |
|
||||
base = {} |
|
||||
for cls in reversed(inspect.getmro(obj.__class__)): |
|
||||
base.update(getattr(cls, 'PARAMS', {})) |
|
||||
obj.__class__._cached_oop_params = base |
|
||||
return obj.__class__._cached_oop_params |
|
||||
|
|
||||
|
|
||||
def load_typed_instance(obj, data): |
|
||||
return load_typed_class(obj, get_params(obj), data) |
|
||||
|
|
||||
|
|
||||
class TypedClass(object): |
|
||||
def __init__(self, **kwargs): |
|
||||
# TODO: validate |
|
||||
self.__dict__.update(kwargs) |
|
||||
|
|
||||
@classmethod |
|
||||
def from_dict(cls, data): |
|
||||
self = cls() |
|
||||
load_typed_instance(self, data) |
|
||||
return self |
|
||||
|
|
||||
def to_dict(self): |
|
||||
return dump_typed_class(self, get_params(self)) |
|
||||
|
|
||||
|
|
||||
def require_implementation(attr): |
|
||||
def _f(self, *args, **kwargs): |
|
||||
raise NotImplementedError('{} must implement method {}', self.__class__.__name, attr) |
|
||||
return _f |
|
@ -0,0 +1,28 @@ |
|||||
|
from __future__ import absolute_import |
||||
|
|
||||
|
import websocket |
||||
|
import gevent |
||||
|
import six |
||||
|
|
||||
|
from disco.util.logging import LoggingClass |
||||
|
|
||||
|
|
||||
|
class Websocket(LoggingClass, websocket.WebSocketApp): |
||||
|
def __init__(self, *args, **kwargs): |
||||
|
LoggingClass.__init__(self) |
||||
|
websocket.WebSocketApp.__init__(self, *args, **kwargs) |
||||
|
def _get_close_args(self, data): |
||||
|
if data and len(data) >= 2: |
||||
|
code = 256 * six.byte2int(data[0:1]) + six.byte2int(data[1:2]) |
||||
|
reason = data[2:].decode('utf-8') |
||||
|
return [code, reason] |
||||
|
return [None, None] |
||||
|
|
||||
|
def _callback(self, callback, *args): |
||||
|
if not callback: |
||||
|
return |
||||
|
|
||||
|
try: |
||||
|
gevent.spawn(callback, self, *args) |
||||
|
except Exception as e: |
||||
|
self.log.exception('Error in Websocket callback for {}: '.format(callback)) |
@ -0,0 +1,119 @@ |
|||||
|
import gevent |
||||
|
|
||||
|
from holster.enum import Enum |
||||
|
from holster.emitter import Emitter |
||||
|
|
||||
|
from disco.util.websocket import Websocket |
||||
|
from disco.util.logging import LoggingClass |
||||
|
from disco.util.json import loads, dumps |
||||
|
from disco.voice.packets import VoiceOPCode |
||||
|
from disco.gateway.packets import OPCode |
||||
|
|
||||
|
VoiceState = Enum( |
||||
|
DISCONNECTED=0, |
||||
|
AWAITING_ENDPOINT=1, |
||||
|
AUTHENTICATING=2, |
||||
|
CONNECTING=3, |
||||
|
CONNECTED=4, |
||||
|
VOICE_CONNECTING=5, |
||||
|
VOICE_CONNECTED=6, |
||||
|
) |
||||
|
|
||||
|
|
||||
|
class VoiceException(Exception): |
||||
|
def __init__(self, msg, client): |
||||
|
self.voice_client = client |
||||
|
super(VoiceException, self).__init__(msg) |
||||
|
|
||||
|
|
||||
|
class VoiceClient(LoggingClass): |
||||
|
def __init__(self, channel): |
||||
|
assert(channel.is_voice) |
||||
|
self.channel = channel |
||||
|
self.client = self.channel.client |
||||
|
|
||||
|
self.packets = Emitter(gevent.spawn) |
||||
|
self.packets.on(VoiceOPCode.READY, self.on_voice_ready) |
||||
|
self.packets.on(VoiceOPCode.SESSION_DESCRIPTION, self.on_voice_sdp) |
||||
|
|
||||
|
# State |
||||
|
self.state = VoiceState.DISCONNECTED |
||||
|
self.connected = gevent.event.Event() |
||||
|
self.token = None |
||||
|
self.endpoint = None |
||||
|
|
||||
|
self.update_listener = None |
||||
|
|
||||
|
# Websocket connection |
||||
|
self.ws = None |
||||
|
|
||||
|
def send(self, op, data): |
||||
|
self.ws.send(dumps({ |
||||
|
'op': op.value, |
||||
|
'd': data, |
||||
|
})) |
||||
|
|
||||
|
def on_voice_ready(self, data): |
||||
|
print data |
||||
|
|
||||
|
def on_voice_sdp(self, data): |
||||
|
print data |
||||
|
|
||||
|
def on_voice_server_update(self, data): |
||||
|
if self.channel.guild_id != data.guild_id or not data.token: |
||||
|
return |
||||
|
|
||||
|
if self.token and self.token != data.token: |
||||
|
return |
||||
|
|
||||
|
self.token = data.token |
||||
|
self.state = VoiceState.AUTHENTICATING |
||||
|
|
||||
|
self.endpoint = 'wss://{}'.format(data.endpoint.split(':', 1)[0]) |
||||
|
self.ws = Websocket( |
||||
|
self.endpoint, |
||||
|
on_message=self.on_message, |
||||
|
on_error=self.on_error, |
||||
|
on_open=self.on_open, |
||||
|
on_close=self.on_close, |
||||
|
) |
||||
|
self.ws.run_forever() |
||||
|
|
||||
|
def on_message(self, ws, msg): |
||||
|
try: |
||||
|
data = loads(msg) |
||||
|
except: |
||||
|
self.log.exception('Failed to parse voice gateway message: ') |
||||
|
|
||||
|
self.packets.emit(VoiceOPCode[data['op']], data) |
||||
|
|
||||
|
def on_error(self, ws, err): |
||||
|
# TODO |
||||
|
self.log.warning('Voice websocket error: {}'.format(err)) |
||||
|
|
||||
|
def on_open(self, ws): |
||||
|
self.send(VoiceOPCode.IDENTIFY, { |
||||
|
'server_id': self.channel.guild_id, |
||||
|
'user_id': self.client.state.me.id, |
||||
|
'session_id': self.client.gw.session_id, |
||||
|
'token': self.token |
||||
|
}) |
||||
|
|
||||
|
def on_close(self, ws): |
||||
|
# TODO |
||||
|
self.log.warning('Voice websocket disconnected') |
||||
|
|
||||
|
def connect(self, timeout=5, mute=False, deaf=False): |
||||
|
self.state = VoiceState.AWAITING_ENDPOINT |
||||
|
|
||||
|
self.update_listener = self.client.events.on('VoiceServerUpdate', self.on_voice_server_update) |
||||
|
|
||||
|
self.client.gw.send(OPCode.VOICE_STATE_UPDATE, { |
||||
|
'self_mute': mute, |
||||
|
'self_deaf': deaf, |
||||
|
'guild_id': int(self.channel.guild_id), |
||||
|
'channel_id': int(self.channel.id), |
||||
|
}) |
||||
|
|
||||
|
if not self.connected.wait(timeout) or self.state != VoiceState.CONNECTED: |
||||
|
raise VoiceException('Failed to connect to voice', self) |
@ -0,0 +1,10 @@ |
|||||
|
from holster.enum import Enum |
||||
|
|
||||
|
VoiceOPCode = Enum( |
||||
|
IDENTIFY=0, |
||||
|
SELECT_PROTOCOL=1, |
||||
|
READY=2, |
||||
|
HEARTBEAT=3, |
||||
|
SESSION_DESCRIPTION=4, |
||||
|
SPEAKING=5, |
||||
|
) |
Loading…
Reference in new issue