mirror of https://gitlab.com/litecord/litecord.git
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:
parent
cbf6a3d441
commit
1bc04449bd
|
|
@ -133,8 +133,18 @@ class MissingPermissions(Forbidden):
|
||||||
class WebsocketClose(Exception):
|
class WebsocketClose(Exception):
|
||||||
@property
|
@property
|
||||||
def code(self):
|
def code(self):
|
||||||
|
from_class = getattr(self, 'close_code', None)
|
||||||
|
|
||||||
|
if from_class:
|
||||||
|
return from_class
|
||||||
|
|
||||||
return self.args[0]
|
return self.args[0]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def reason(self):
|
def reason(self):
|
||||||
|
from_class = getattr(self, 'close_code', None)
|
||||||
|
|
||||||
|
if from_class:
|
||||||
|
return self.args[0]
|
||||||
|
|
||||||
return self.args[1]
|
return self.args[1]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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:
|
class VoiceWebsocket:
|
||||||
"""Voice websocket class."""
|
"""Voice websocket class.
|
||||||
|
|
||||||
|
Implements the Discord Voice Websocket Protocol Version 4.
|
||||||
|
"""
|
||||||
def __init__(self, ws, app):
|
def __init__(self, ws, app):
|
||||||
self.ws = ws
|
self.ws = ws
|
||||||
self.app = app
|
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):
|
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))
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from litecord.voice.websocket import VoiceWebsocket
|
from litecord.voice.websocket import VoiceWebsocket
|
||||||
|
|
||||||
|
|
||||||
async def voice_websocket_handler(app, ws, url):
|
async def voice_websocket_handler(app, ws, url):
|
||||||
"""Main handler to instantiate a VoiceWebsocket
|
"""Main handler to instantiate a VoiceWebsocket
|
||||||
with the given url."""
|
with the given url."""
|
||||||
|
|
@ -30,13 +31,12 @@ async def voice_websocket_handler(app, ws, url):
|
||||||
try:
|
try:
|
||||||
gw_version = args['v'][0]
|
gw_version = args['v'][0]
|
||||||
except (KeyError, IndexError):
|
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')
|
return await ws.close(1000, 'Invalid gateway version')
|
||||||
|
|
||||||
# TODO: select a different VoiceWebsocket runner depending on
|
# TODO: select a different VoiceWebsocket runner depending on the selected
|
||||||
# version. however i do not have docs on voice websockets
|
# version.
|
||||||
# earlier than v3.
|
|
||||||
vws = VoiceWebsocket(ws, app)
|
vws = VoiceWebsocket(ws, app)
|
||||||
await vws.run()
|
await vws.run()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue