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)
This commit is contained in:
Luna 2019-03-01 02:25:22 -03:00
parent cbf6a3d441
commit 1bc04449bd
5 changed files with 274 additions and 7 deletions

View File

@ -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]

54
litecord/voice/errors.py Normal file
View File

@ -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 <http://www.gnu.org/licenses/>.
"""
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

32
litecord/voice/opcodes.py Normal file
View File

@ -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 <http://www.gnu.org/licenses/>.
"""
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

View File

@ -17,11 +17,182 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
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))

View File

@ -20,6 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
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()