From b61bb499495f5769e9a705b0158e7bcaceaa4ea6 Mon Sep 17 00:00:00 2001 From: Zomatree Date: Fri, 31 Dec 2021 04:52:56 +0000 Subject: [PATCH] inital voice support --- requirements.txt | 2 + revolt/channel.py | 21 ++++ revolt/client.py | 2 +- revolt/http.py | 5 +- revolt/srtp.py | 36 +++++++ revolt/state.py | 7 +- revolt/types/__init__.py | 1 + revolt/types/http.py | 6 +- revolt/types/voice.py | 192 ++++++++++++++++++++++++++++++++++++ revolt/voice_client.py | 204 +++++++++++++++++++++++++++++++++++++++ 10 files changed, 470 insertions(+), 6 deletions(-) create mode 100755 revolt/srtp.py create mode 100755 revolt/types/voice.py create mode 100755 revolt/voice_client.py diff --git a/requirements.txt b/requirements.txt index dedee8e..1e706a5 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ aiohttp==3.7.4.post0 ulid-py==1.1.0 aenum==3.1.0 +typing_extensions +pynacl==1.4.0 diff --git a/revolt/channel.py b/revolt/channel.py index 7dfd6d3..ed07b8f 100755 --- a/revolt/channel.py +++ b/revolt/channel.py @@ -1,10 +1,13 @@ from __future__ import annotations from typing import TYPE_CHECKING, Optional, cast +import asyncio from .enums import ChannelType from .messageable import Messageable from .permissions import ChannelPermissions +from .voice_client import VoiceClient +from .errors import FeatureDisabled if TYPE_CHECKING: from .message import Message @@ -179,6 +182,24 @@ class VoiceChannel(Channel): """ await self.state.http.set_channel_role_permissions(self.id, role.id, permissions.value) + async def connect(self): + token = (await self.state.http.connect_to_voice(self.id))["token"] + voso = self.state.api_info["features"]["voso"] + + if not voso["enabled"]: + raise FeatureDisabled("vortex is disabled.") + + voice_client = VoiceClient(self.state.http.session, voso["ws"], self.id, token, self.state) + + await voice_client.start() + await voice_client.send_authenticate() + print("auth") + await voice_client.send_initialize_transport() + print("init") + await voice_client.send_connect_transport() + print("connect") + return voice_client + def channel_factory(data: ChannelPayload, state: State) -> Channel: if data["channel_type"] == "SavedMessage": return SavedMessageChannel(data, state) diff --git a/revolt/client.py b/revolt/client.py index 93121c5..f6777c4 100755 --- a/revolt/client.py +++ b/revolt/client.py @@ -87,7 +87,7 @@ class Client: self.api_info = api_info self.http = HttpClient(self.session, self.token, self.api_url, self.api_info) - self.state = State(self.http, api_info, self.max_messages) + self.state = State(self.http, api_info, self.max_messages, self.dispatch) self.websocket = WebsocketHandler(self.session, self.token, api_info["ws"], self.dispatch, self.state) await self.websocket.start() diff --git a/revolt/http.py b/revolt/http.py index e56d271..59fdd2b 100755 --- a/revolt/http.py +++ b/revolt/http.py @@ -28,7 +28,7 @@ if TYPE_CHECKING: from .types import Member from .types import Message as MessagePayload from .types import (MessageReplyPayload, MessageWithUserData, Role, Server, - ServerBans, ServerInvite, TextChannel) + ServerBans, ServerInvite, TextChannel, JoinCallResponse) from .types import User as UserPayload from .types import UserProfile, VoiceChannel @@ -352,3 +352,6 @@ class HttpClient: def delete_role(self, server_id: str, role_id: str) -> Request[None]: return self.request("DELETE", f"/servers/{server_id}/roles/{role_id}") + + def connect_to_voice(self, channel_id) -> Request[JoinCallResponse]: + return self.request("POST", f"/channels/{channel_id}/join_call") diff --git a/revolt/srtp.py b/revolt/srtp.py new file mode 100755 index 0000000..4a60c76 --- /dev/null +++ b/revolt/srtp.py @@ -0,0 +1,36 @@ +import time +import random +import bitarray + +class SecureRTP: + def __init__(self, salt: str, hash: str): + self.salt = salt + self.hash = hash + self.roc = 0 + self.seq = 0 + self.packet_counter = 0 + self.ssrc = random.randbytes(4) # 32 bytes + self.version = [1, 0] # binary for 2 + + # documentation of header can be found at https://datatracker.ietf.org/doc/html/rfc3550#section-5.1 + + def create_packet(self, data: bytes) -> bytes: + arr = bitarray.bitarray() + + arr.extend(self.version) + arr.extend([0, 0]) + arr.extend([0, 0, 0, 0]) + arr.append(0) + arr.extend([0, 0, 0, 0, 0, 0, 0]) + + seq = list(map(int, bin(self.seq)[2:])) + arr.extend(([0] * (16 - len(seq))) + seq) + + timestamp = list(map(int, bin(int(time.monotonic()))[2:])) + + arr.extend(([0] * (32 - len(timestamp))) + timestamp) + arr.extend(self.ssrc) + + arr.extend(data) + + return arr.tobytes() diff --git a/revolt/state.py b/revolt/state.py index 6242d85..7c36bcb 100755 --- a/revolt/state.py +++ b/revolt/state.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections import deque -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Callable, Optional from .channel import Channel, channel_factory from .member import Member @@ -22,12 +22,13 @@ if TYPE_CHECKING: __all__ = ("State",) class State: - __slots__ = ("http", "api_info", "max_messages", "users", "channels", "servers", "messages") + __slots__ = ("http", "api_info", "max_messages", "users", "channels", "servers", "messages", "dispatch") - def __init__(self, http: HttpClient, api_info: ApiInfo, max_messages: int): + def __init__(self, http: HttpClient, api_info: ApiInfo, max_messages: int, dispatch: Callable[..., None]): self.http = http self.api_info = api_info self.max_messages = max_messages + self.dispatch = dispatch self.users: dict[str, User] = {} self.channels: dict[str, Channel] = {} diff --git a/revolt/types/__init__.py b/revolt/types/__init__.py index 4ce0865..9fb4b48 100755 --- a/revolt/types/__init__.py +++ b/revolt/types/__init__.py @@ -10,3 +10,4 @@ from .message import * from .role import * from .server import * from .user import * +from .voice import * diff --git a/revolt/types/http.py b/revolt/types/http.py index 5b1cacd..d290cd2 100755 --- a/revolt/types/http.py +++ b/revolt/types/http.py @@ -7,13 +7,14 @@ if TYPE_CHECKING: from .message import Message from .user import User - + __all__ = ( "VosoFeature", "ApiInfo", "Autumn", "GetServerMembers", "MessageWithUserData", + "JoinCallResponse" ) @@ -50,3 +51,6 @@ class MessageWithUserData(TypedDict): messages: list[Message] members: list[Member] users: list[User] + +class JoinCallResponse(TypedDict): + token: str diff --git a/revolt/types/voice.py b/revolt/types/voice.py new file mode 100755 index 0000000..1f6e8c0 --- /dev/null +++ b/revolt/types/voice.py @@ -0,0 +1,192 @@ +from typing import Optional, TypedDict, Union, Literal +from typing_extensions import NotRequired + +TransportProtocol = Literal["TCP", "UDP"] + +class BaseWS(TypedDict): + id: NotRequired[int] + +class AuthenticationCommandData(TypedDict): + token: str + roomId: str + +class AuthenticateCommand(BaseWS): + type: Literal["Authenticate"] + + data: AuthenticationCommandData + +class RTPCodecFeedback(TypedDict): + parameter: str + type: str + +class RTPCodec(TypedDict): + channels: int + clockRate: int + kind: str + mimeType: str + parameters: dict + preferredPayloadType: int + rtcpFeedback: list[RTPCodecFeedback] + +class RTPHeaderExtension(TypedDict): + direction: str + kind: str + preferredEncrypt: bool + preferredId: int + uri: str + +class RTPCapabilities(TypedDict): + codecs: list[RTPCodec] + headerExtensions: list[RTPHeaderExtension] + +class AuthenticationResponseData(TypedDict): + roomId: str + userId: str + version: str + rtpCapabilities: RTPCapabilities + +class AuthenticateResponse(BaseWS): + type: Literal["Authenticate"] + data: AuthenticationResponseData + +class RoomInfoCommand(BaseWS): + type: Literal["RoomInfo"] + +class VoiceUser(TypedDict): + audio: bool + +class RoomInfoResponseData(TypedDict): + id: str + users: dict[str, VoiceUser] + videoAllowed: bool + +class RoomInfoResponse(BaseWS): + type: Literal["RoomInfo"] + data: RoomInfoResponseData + +class InitializeTransportCommandData(TypedDict): + mode: Literal["SplitWebRTC", "CombinedWebRTC", "CombinedRTP"] + rtpCapabilities: RTPCapabilities + +class InitializeTransportCommand(BaseWS): + type: Literal["InitializeTransports"] + data: InitializeTransportCommandData + +class IceParameters(TypedDict): + usernameFragment: str + password: str + iceLite: NotRequired[bool] + +class IceCandidate(TypedDict): + foundation: str + priority: str + ip: str + protocol: TransportProtocol + tcp_type: NotRequired[Literal["passive"]] + +class DTLSFingerprints(TypedDict): + algorithm: Literal["Sha1", "Sha224", "Sha256", "Sha384", "Sha512"] + value: str + +class DtlsParameters(TypedDict): + role: Literal["auto", "client", "server"] + fingerprints: list[DTLSFingerprints] + +class SctpParameters(TypedDict): + port: int + OS: int + MIS: int + max_message_size: int + +class TransportInfo(TypedDict): + id: str + iceParameters: IceParameters + iceCandidates: list[IceCandidate] + dtlsParameters: DtlsParameters + sctpParameters: Optional[SctpParameters] + +class InitializeTransportResponseDataSplitWebRTC(TypedDict): + recvTransport: TransportInfo + sendTransport: TransportInfo + +class InitializeTransportResponseDataCombinedWebRTC(TypedDict): + transport: TransportInfo + +class InitializeTransportResponseDataCombinedRtp(TypedDict): + ip: bytes + port: int + protocol: TransportProtocol + id: str + srtpCryptoSuite: Literal["AES_CM_128_HMAC_SHA1_80", "AES_CM_128_HMAC_SHA1_32"] + +InitializeTransportResponseData = Union[InitializeTransportResponseDataSplitWebRTC, InitializeTransportResponseDataCombinedWebRTC, InitializeTransportResponseDataCombinedRtp] + +class InitializeTransportResponse(BaseWS): + type: Literal["InitializeTransports"] + data: InitializeTransportResponseData + +class SrtpParameters(TypedDict): + cryptoSuite: Literal["AES_CM_128_HMAC_SHA1_80", "AES_CM_128_HMAC_SHA1_32"] + keyBase64: str + +class ConnectTransportData(TypedDict): + id: str + dtlsParameters: NotRequired[DtlsParameters] + srtpParameters: NotRequired[SrtpParameters] + +class ConnectTransportCommand(BaseWS): + type: Literal["ConnectTransport"] + data: ConnectTransportData + +class ConnectTransportResponse(BaseWS): + type: Literal["ConnectTransport"] + +class RTPEncodingRtx(TypedDict): + ssrc: int + +class RTPEncoding(TypedDict, total=False): + ssrc: int + rid: str + codec_payload_type: int + rtx: RTPEncodingRtx + dtx: bool + scalability_mode: str + scale_resolution_down_by: float + max_bitrate: int + +class RTPHeaderExtensionParameters(TypedDict): + uri: str + id: int + encrypt: bool + +class RtcpParameters(TypedDict): + cname: NotRequired[str] + reduceSize: bool + mux: NotRequired[bool] + +class RTPParameters(TypedDict): + mid: NotRequired[str] + codecs: list[RTPCodec] + headerExtensions: list[RTPHeaderExtensionParameters] + encodings: list[RTPEncoding] + rtcp: RtcpParameters + +class StartProduceCommandData(TypedDict): + type: Literal["audio", "video", "saudio", "svideo"] + rtpParameters: RTPParameters + +class StartProduceCommand(BaseWS): + type: Literal["StartProduce"] + data: StartProduceCommandData + +class StartProduceResponseData(TypedDict): + producerId: str + +class StartProduceResponse(BaseWS): + type: Literal["StartProduce"] + data: StartProduceResponseData + +WSCommand = Union[AuthenticateCommand, RoomInfoCommand, InitializeTransportCommand, ConnectTransportCommand, StartProduceCommand] +WSResponse = Union[AuthenticateResponse, RoomInfoResponse, InitializeTransportResponse, ConnectTransportResponse, StartProduceResponse] + +__all__ = ("BaseWS", "WSCommand", "WSResponse", "AuthenticateCommand", "RoomInfoCommand", "InitializeTransportCommand", "ConnectTransportCommand", "StartProduceCommand", "AuthenticateResponse", "RoomInfoResponse", "InitializeTransportResponse", "ConnectTransportResponse", "StartProduceResponse") diff --git a/revolt/voice_client.py b/revolt/voice_client.py new file mode 100755 index 0000000..c7dbe9d --- /dev/null +++ b/revolt/voice_client.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import json +import logging +import asyncio +import aiohttp +import random +import string +import nacl +import nacl.public +import nacl.encoding +import nacl.utils +import base64 + +from typing import TYPE_CHECKING, Literal, BinaryIO + +from revolt.types.voice import AuthenticationResponseData, InitializeTransportResponseDataCombinedRtp, RTPCapabilities, RTPHeaderExtensionParameters, StartProduceResponseData + +if TYPE_CHECKING: + from .state import State + from .types.voice import * + +__all__ = ("VoiceClient",) + +logger = logging.getLogger("revolt.voice_client") + +class VoiceClient: + __slots__ = ("session", "token", "websocket", "dispatch", "state", "ws_url", "channel_id", "loop", "id", "futures", "capabilities", "rtp_info", "ssrc", "cname", "producer_id", "key", "salt") + + def __init__(self, session: aiohttp.ClientSession, ws_url: str, channel_id: str, token: str, state: State): + self.id = 0 + self.session = session + self.ws_url = ws_url + self.channel_id = channel_id + self.websocket: aiohttp.ClientWebSocketResponse + self.token = token + self.dispatch = state.dispatch + self.state = state + self.loop = asyncio.get_running_loop() + self.futures = dict[int, asyncio.Future]() + self.ssrc = random.randint(0, 1 << 31) + self.cname = "".join(random.choices(string.ascii_letters, k=15)) + + self.capabilities: RTPCapabilities + self.rtp_info: InitializeTransportResponseDataCombinedRtp + self.producer_id: str + self.key = nacl.utils.random(16) + self.salt = nacl.utils.random(14) + + async def send_payload(self, payload) -> int: + self.id += 1 + payload["id"] = self.id + + logging.info(payload) + + await self.websocket.send_str(json.dumps(payload)) + + return self.id + + async def heartbeat(self): + while not self.websocket.closed: + # logger.info("Sending voice hearbeat") + await self.websocket.ping() + await asyncio.sleep(15) + + async def send_authenticate(self): + payload: AuthenticateCommand = { + "type": "Authenticate", + "data": { + "token": self.token, + "roomId": self.channel_id + } + } + + id = await self.send_payload(payload) + response: AuthenticationResponseData = await self.set_future(id) + self.capabilities = response["rtpCapabilities"] + + async def send_initialize_transport(self): + payload: InitializeTransportCommand = { + "type": "InitializeTransports", + "data": { + "mode": "CombinedRTP", + "rtpCapabilities": self.capabilities + } + } + + id = await self.send_payload(payload) + response: InitializeTransportResponseDataCombinedRtp = await self.set_future(id) + self.rtp_info = response + + async def send_connect_transport(self): + payload: ConnectTransportCommand = { + "type": "ConnectTransport", + "data": { + "srtpParameters": { + "cryptoSuite": self.rtp_info["srtpCryptoSuite"], + "keyBase64": base64.b64encode(self.salt + self.key).decode(), + }, + "id": self.rtp_info["id"], + } + } + + await self.send_payload(payload) + + async def send_start_produce(self, type: Literal["audio", "video", "saudio", "svideo"]): + header_extensions: list[RTPHeaderExtensionParameters] = [{"uri": d["uri"], "id": d["preferredId"], "encrypt": d["preferredEncrypt"]} for d in self.capabilities["headerExtensions"]] + payload: StartProduceCommand = { + "type": "StartProduce", + "data": { + "type": type, + "rtpParameters": { + "codecs": self.capabilities["codecs"], + "headerExtensions": header_extensions, + "encodings": [{ + "ssrc": self.ssrc, + }], + "mid": "0", + "rtcp": { + "cname": self.cname, + "reduceSize": True + } + } + } + } + + id = await self.send_payload(payload) + response: StartProduceResponseData = await self.set_future(id) + producer_id = response["producerId"] + self.producer_id = producer_id + + async def handle_event(self, payload: WSResponse): + event_type = payload["type"].lower() + logger.debug("Recieved event %s %s", event_type, payload) + + try: + func = getattr(self, f"handle_{event_type}") + except: + logger.debug("Unknown event '%s'", event_type) + return + + await func(payload) + + async def handle_authenticate(self, _): + logger.info("Successfully authenticated") + + async def set_future(self, id: int): + future = asyncio.Future() + self.futures[id] = future + return await future + + async def websocket_loop(self): + async for msg in self.websocket: + payload: WSResponse = json.loads(msg.data) + print(payload) + + if future := self.futures.get(payload.get("id", "")): + future.set_result(payload.get("data")) + + self.loop.create_task(self.handle_event(payload)) + + async def start(self): + self.websocket = await self.session.ws_connect(self.ws_url) + + asyncio.create_task(self.heartbeat()) + asyncio.create_task(self.websocket_loop()) + + async def send_audio(self, audio: BinaryIO): + await self.send_start_produce("audio") + + +""" +{ + 'type': 'StartProduce', + 'data': { + 'type': 'audio', + 'rtpParameters': { + 'codecs': [ + {'kind': 'audio', 'mimeType': 'audio/opus', 'preferredPayloadType': 100, 'clockRate': 48000, 'channels': 2, 'parameters': {}, 'rtcpFeedback': [{'type': 'transport-cc', 'parameter': ''}]} + ], + 'headerExtensions': [{'uri': 'urn:ietf:params:rtp-hdrext:sdes:mid', 'id': 1, 'encrypt': False}, {'uri': 'urn:ietf:params:rtp-hdrext:sdes:mid', 'id': 1, 'encrypt': False}, {'uri': 'urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id', 'id': 2, 'encrypt': False}, {'uri': 'urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id', 'id': 3, 'encrypt': False}, {'uri': 'http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time', 'id': 4, 'encrypt': False}, {'uri': 'http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time', 'id': 4, 'encrypt': False}, {'uri': 'http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01', 'id': 5, 'encrypt': False}, {'uri': 'http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01', 'id': 5, 'encrypt': False}, {'uri': 'http://tools.ietf.org/html/draft-ietf-avtext-framemarking-07', 'id': 6, 'encrypt': False}, {'uri': 'urn:ietf:params:rtp-hdrext:framemarking', 'id': 7, 'encrypt': False}, {'uri': 'urn:ietf:params:rtp-hdrext:ssrc-audio-level', 'id': 10, 'encrypt': False}, {'uri': 'urn:3gpp:video-orientation', 'id': 11, 'encrypt': False}, {'uri': 'urn:ietf:params:rtp-hdrext:toffset', 'id': 12, 'encrypt': False}], + 'encodings': [{'ssrc': 896646738}], + 'mid': '0', + 'rtcp': {'cname': 'iUaoUqyQjShruzl', 'reduceSize': True}} + }, + 'id': 4} +""" +""" +{ + "type": "StartProduce", + "data": { + "type":"audio", + "rtpParameters": { + "codecs": [ + {"mimeType":"audio/opus","payloadType":111,"clockRate":48000,"channels":2,"parameters":{"minptime":10,"useinbandfec":1},"rtcpFeedback":[{"type":"transport-cc","parameter":""}]} + ], + "headerExtensions":[{"uri":"urn:ietf:params:rtp-hdrext:sdes:mid","id":4,"encrypt":false,"parameters":{}},{"uri":"http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time","id":2,"encrypt":false,"parameters":{}},{"uri":"http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01","id":3,"encrypt":false,"parameters":{}},{"uri":"urn:ietf:params:rtp-hdrext:ssrc-audio-level","id":1,"encrypt":false,"parameters":{}}], + "encodings":[{"ssrc":2276570677,"dtx":false}], + "mid":"0"} + "rtcp":{"cname":"KAhFB58ELM5Tf+9a","reducedSize":true}, + } + } + "id":7, +"""