From 1bc04449bd97fed680ac9f12cb07d73f801c9133 Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 1 Mar 2019 02:25:22 -0300 Subject: [PATCH] add some dummy impl for vws v4 will be removed as this should be on the voice server instead. litecord manages simple voice state and dispatches voice state updates (such as speaking=1) --- litecord/errors.py | 10 ++ litecord/voice/errors.py | 54 +++++++++ litecord/voice/opcodes.py | 32 +++++ litecord/voice/websocket.py | 175 +++++++++++++++++++++++++++- litecord/voice/websocket_starter.py | 10 +- 5 files changed, 274 insertions(+), 7 deletions(-) create mode 100644 litecord/voice/errors.py create mode 100644 litecord/voice/opcodes.py 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()