diff --git a/litecord/errors.py b/litecord/errors.py index 1e9942a..64c0ebf 100644 --- a/litecord/errors.py +++ b/litecord/errors.py @@ -133,8 +133,18 @@ class MissingPermissions(Forbidden): class WebsocketClose(Exception): @property def code(self): + from_class = getattr(self, 'close_code', None) + + if from_class: + return from_class + return self.args[0] @property def reason(self): + from_class = getattr(self, 'close_code', None) + + if from_class: + return self.args[0] + return self.args[1] diff --git a/litecord/voice/errors.py b/litecord/voice/errors.py new file mode 100644 index 0000000..c30201f --- /dev/null +++ b/litecord/voice/errors.py @@ -0,0 +1,54 @@ +""" + +Litecord +Copyright (C) 2018-2019 Luna Mendes + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, version 3 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +""" + +from litecord.errors import WebsocketClose + + +class UnknownOPCode(WebsocketClose): + close_code = 4000 + +class NotAuthenticated(WebsocketClose): + close_code = 4003 + +class AuthFailed(WebsocketClose): + close_code = 4004 + +class AlreadyAuth(WebsocketClose): + close_code = 4005 + +class InvalidSession(WebsocketClose): + close_code = 4006 + +class SessionTimeout(WebsocketClose): + close_code = 4009 + +class ServerNotFound(WebsocketClose): + close_code = 4011 + +class UnknownProtocol(WebsocketClose): + close_code = 4012 + +class Disconnected(WebsocketClose): + close_code = 4014 + +class VoiceServerCrash(WebsocketClose): + close_code = 4015 + +class UnknownEncryption(WebsocketClose): + close_code = 4016 diff --git a/litecord/voice/opcodes.py b/litecord/voice/opcodes.py new file mode 100644 index 0000000..ca1ca27 --- /dev/null +++ b/litecord/voice/opcodes.py @@ -0,0 +1,32 @@ +""" + +Litecord +Copyright (C) 2018-2019 Luna Mendes + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, version 3 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +""" + +class VoiceOP: + 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 diff --git a/litecord/voice/websocket.py b/litecord/voice/websocket.py index 48146af..5234b86 100644 --- a/litecord/voice/websocket.py +++ b/litecord/voice/websocket.py @@ -17,11 +17,182 @@ along with this program. If not, see . """ +import json +from dataclasses import dataclass, asdict, field + +import websockets.errors +from logbook import Logger + +from litecord.voice.opcodes import VoiceOP + +from litecord.errors import WebsocketClose +from litecord.voice.errors import ( + UnknownOPCode, AuthFailed, UnknownProtocol, InvalidSession +) + +from litecord.enums import ChannelType, VOICE_CHANNELS + +log = Logger(__name__) + + +@dataclass +class VoiceState: + """Store a voice websocket's state.""" + server_id: int + user_id: int + + def __bool__(self): + as_dict = asdict(self) + return all(bool(v) for v in as_dict.values()) + + class VoiceWebsocket: - """Voice websocket class.""" + """Voice websocket class. + + Implements the Discord Voice Websocket Protocol Version 4. + """ def __init__(self, ws, app): self.ws = ws self.app = app + self.voice = app.voice + self.storage = app.storage + + self.state = None + + async def send_op(self, opcode: VoiceOP, data: dict): + """Send a message through the websocket.""" + encoded = json.dumps({ + 'op': opcode, + 'd': data + }) + + await self.ws.send(encoded) + + async def _handle_0(self, msg: dict): + """Handle OP 0 Identify.""" + data = msg['d'] + + # NOTE: there is a data.video, but we don't handle video. + try: + server_id = int(data['server_id']) + user_id = int(data['user_id']) + session_id = data['session_id'] + token = data['token'] + except (KeyError, ValueError): + raise AuthFailed('Invalid identify payload') + + # server_id can be a: + # - voice channel id + # - dm id + # - group dm id + + channel = await self.storage.get_channel(server_id) + ctype = ChannelType(channel['type']) + + if ctype not in VOICE_CHANNELS: + raise AuthFailed('invalid channel id') + + v_user_id = await self.voice.authenticate(token, session_id) + + if v_user_id != user_id: + raise AuthFailed('invalid user id') + + await self.send_op(VoiceOP.hello, { + 'v': 4, + 'heartbeat_interval': 10000, + }) + + # TODO: get ourselves a place on the voice server + place = await self.voice.get_place(server_id) + + if not place: + raise InvalidSession('invalid voice place') + + self.state = VoiceState(place.server_id, user_id) + + await self.send_op(VoiceOP.ready, { + 'ssrc': place.ssrc, + 'port': place.port, + 'modes': place.modes, + 'ip': place.ip, + }) + + async def _handle_1(self, msg: dict): + """Handle 1 Select Protocol.""" + data = msg['d'] + + try: + protocol = data['protocol'] + proto_data = data['data'] + except KeyError: + raise UnknownProtocol('invalid select protocol') + + if protocol != 'udp': + raise UnknownProtocol('invalid protocol') + + try: + client_addr = proto_data['address'] + client_port = proto_data['port'] + client_mode = proto_data['mode'] + except KeyError: + raise UnknownProtocol('incomplete protocol data') + + # signal the voice server about (address, port) + mode + session = await self.voice.register( + self.state.server_id, + client_addr, client_port, client_mode + ) + + await self.send_op(VoiceOP.session_description, { + 'video_codec': 'VP8', + 'secret_key': session.key, + 'mode': session.mode, + 'media_session_id': session.sess_id, + 'audio_codec': 'opus' + }) + + async def _handle_3(self, msg: dict): + """Handle 3 Heartbeat.""" + await self.send_op(VoiceOP.heartbeat_ack, { + 'd': msg['d'] + }) + + async def _handle_5(self, msg: dict): + """Handle 5 Speaking.""" + if not self.state: + return + + await self.voice.update(self.state, msg['d']) + + async def _handle_7(self, msg: dict): + """Handle 7 Resume.""" + pass + + async def _process_msg(self): + msg = await self.ws.recv() + msg = json.loads(msg) + op_code = msg['op'] + + try: + handler = getattr(self, f'_handle_{op_code}') + except AttributeError: + raise UnknownOPCode('Unknown OP code.') + + await handler(msg) + + async def _loop(self): + while True: + await self._process_msg() async def run(self): - pass + """Main entry point for a voice websocket.""" + try: + await self._loop() + except websockets.exceptions.ConnectionClosed as err: + log.warning('conn close, state={}, err={}', self.state, err) + except WebsocketClose as err: + log.warning('ws close, state={} err={}', self.state, err) + await self.ws.close(code=err.code, reason=err.reason) + except Exception as err: + log.exception('An exception has occoured. state={}', self.state) + await self.ws.close(code=4000, reason=repr(err)) diff --git a/litecord/voice/websocket_starter.py b/litecord/voice/websocket_starter.py index 002835c..27640f5 100644 --- a/litecord/voice/websocket_starter.py +++ b/litecord/voice/websocket_starter.py @@ -20,6 +20,7 @@ along with this program. If not, see . import urllib.parse from litecord.voice.websocket import VoiceWebsocket + async def voice_websocket_handler(app, ws, url): """Main handler to instantiate a VoiceWebsocket with the given url.""" @@ -30,13 +31,12 @@ async def voice_websocket_handler(app, ws, url): try: gw_version = args['v'][0] except (KeyError, IndexError): - gw_version = '3' + gw_version = '4' - if gw_version not in ('1', '2', '3'): + if gw_version not in ('1', '2', '3', '4'): return await ws.close(1000, 'Invalid gateway version') - # TODO: select a different VoiceWebsocket runner depending on - # version. however i do not have docs on voice websockets - # earlier than v3. + # TODO: select a different VoiceWebsocket runner depending on the selected + # version. vws = VoiceWebsocket(ws, app) await vws.run()