1 Commits

Author SHA1 Message Date
Zomatree b61bb49949 inital voice support 2021-12-31 04:52:56 +00:00
10 changed files with 470 additions and 6 deletions
+2
View File
@@ -1,3 +1,5 @@
aiohttp==3.7.4.post0
ulid-py==1.1.0
aenum==3.1.0
typing_extensions
pynacl==1.4.0
+21
View File
@@ -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)
+1 -1
View File
@@ -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()
+4 -1
View File
@@ -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")
Executable
+36
View File
@@ -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()
+4 -3
View File
@@ -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] = {}
+1
View File
@@ -10,3 +10,4 @@ from .message import *
from .role import *
from .server import *
from .user import *
from .voice import *
+5 -1
View File
@@ -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
+192
View File
@@ -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")
+204
View File
@@ -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,
"""