Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rough draft of voice #230

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 81 additions & 2 deletions nextcore/http/client/base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,15 @@
from abc import ABC, abstractmethod
from logging import getLogger
from typing import TYPE_CHECKING
from nextcore.common import UNDEFINED

from ..route import Route

if TYPE_CHECKING:
from typing import Any, Final
from typing import Any, Final, Literal
from nextcore.common import UndefinedType

from aiohttp import ClientResponse
from aiohttp import ClientResponse, ClientWebSocketResponse

logger = getLogger(__name__)

Expand Down Expand Up @@ -58,3 +60,80 @@ async def request(
**kwargs: Any,
) -> ClientResponse:
...

@abstractmethod
async def connect_to_gateway(
self,
*,
version: Literal[6, 7, 8, 9, 10] | UndefinedType = UNDEFINED,
encoding: Literal["json", "etf"] | UndefinedType = UNDEFINED,
compress: Literal["zlib-stream"] | UndefinedType = UNDEFINED,
) -> ClientWebSocketResponse:
"""Connects to the gateway

**Example usage:**

.. code-block:: python

ws = await http_client.connect_to_gateway()


Parameters
----------
version:
The major API version to use

.. hint::
It is a good idea to pin this to make sure something doesn't unexpectedly change
encoding:
Whether to use json or etf for payloads
compress:
Payload compression from data sent from Discord.

Returns
-------
aiohttp.ClientWebSocketResponse
The gateway websocket
"""

...


@abstractmethod
async def connect_to_voice_websocket(
self,
endpoint: str,
*,
version: Literal[1,2,3,4] | UndefinedType = UNDEFINED,
) -> ClientWebSocketResponse:
"""Connects to the voice WebSocket gateway

**Example usage:**

.. code-block:: python

ws = await http_client.connect_to_voice_websocket()


Parameters
----------
endpoint:
The voice server to connect to.

.. note::
This can obtained from the `voice server update event <https://discord.dev/topics/gateway-events#voice-server-update>` and is usually in the format of ``servername.discord.media:443``
version:
The major API version to use

.. hint::
It is a good idea to pin this to make sure something doesn't unexpectedly change
.. note::
A list of versions can be found on the `voice versioning page <https://discord.dev/topics/voice-connections#voice-gateway-versioning>`__

Returns
-------
aiohttp.ClientWebSocketResponse
The voice websocket gateway
"""

...
59 changes: 59 additions & 0 deletions nextcore/http/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,65 @@ async def connect_to_gateway(
# TODO: Aiohttp bug
return await self._session.ws_connect("wss://gateway.discord.gg", params=params) # type: ignore [reportUnknownMemberType]


async def connect_to_voice_websocket(
self,
endpoint: str,
*,
version: Literal[1,2,3,4] | UndefinedType = UNDEFINED,
) -> ClientWebSocketResponse:
"""Connects to the voice WebSocket gateway

**Example usage:**

.. code-block:: python

ws = await http_client.connect_to_voice_websocket()


Parameters
----------
endpoint:
The voice server to connect to.

.. note::
This can obtained from the `voice server update event <https://discord.dev/topics/gateway-events#voice-server-update>` and is usually in the format of ``servername.discord.media:443``
version:
The major API version to use

.. hint::
It is a good idea to pin this to make sure something doesn't unexpectedly change
.. note::
A list of versions can be found on the `voice versioning page <https://discord.dev/topics/voice-connections#voice-gateway-versioning>`__

Raises
------
RuntimeError
:meth:`HTTPClient.setup` was not called yet.
RuntimeError
HTTPClient was closed.

Returns
-------
aiohttp.ClientWebSocketResponse
The voice websocket gateway
"""

if self._session is None:
raise RuntimeError("HTTPClient.setup was not called yet!")
if self._session.closed:
raise RuntimeError("HTTPClient is closed!")

params = {}

# These have different behaviour when not provided and set to None.
# This only adds them if they are provided (not Undefined)
if version is not UNDEFINED:
params["version"] = version

# TODO: Aiohttp bug
return await self._session.ws_connect("wss://" + endpoint, params=params) # type: ignore [reportUnknownMemberType]

async def _get_bucket(self, route: Route, rate_limit_storage: RateLimitStorage) -> Bucket:
"""Gets a bucket object for a route.

Expand Down
1 change: 1 addition & 0 deletions nextcore/voice/opcodes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from enum import IntEnum
61 changes: 61 additions & 0 deletions nextcore/voice/udp_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# The MIT License (MIT)
# Copyright (c) 2021-present nextcore developers
#
# Permission is hereby granted, free of charge, to any person obtaining a
# copy of this software and associated documentation files (the "Software"),
# to deal in the Software without restriction, including without limitation
# the rights to use, copy, modify, merge, publish, distribute, sublicense,
# and/or sell copies of the Software, and to permit persons to whom the
# Software is furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
# DEALINGS IN THE SOFTWARE.

from __future__ import annotations
import asyncio
from logging import getLogger
from typing import TYPE_CHECKING
import anyio
import struct

VoicePacketHeader = struct.Struct(">HH")

if TYPE_CHECKING:
from anyio.abc import ConnectedUDPSocket

__all__ = ("UDPClient", )

_logger = getLogger(__name__)

class UDPClient:
def __init__(self) -> None:
self.socket: ConnectedUDPSocket | None = None

async def send(self, message: bytes):
assert self.socket is not None
_logger.debug("Sent %s", hex(int.from_bytes(message, byteorder="big")))
await self.socket.send(message)



async def connect(self, host: str, port: int):
_logger.info("Connecting to %s:%s", host, port)
self.socket = await anyio.create_connected_udp_socket(host, port)
_logger.debug("Connected to %s:%s", host, port)

async def receive_loop(self):
assert self.socket is not None
_logger.debug("Started udp receive loop")

async for message in self.socket:
pass
# _logger.debug("Received %s", message)

145 changes: 145 additions & 0 deletions nextcore/voice/voice_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# The MIT License (MIT)
# Copyright (c) 2021-present nextcore developers
#
# Permission is hereby granted, free of charge, to any person obtaining a
# copy of this software and associated documentation files (the "Software"),
# to deal in the Software without restriction, including without limitation
# the rights to use, copy, modify, merge, publish, distribute, sublicense,
# and/or sell copies of the Software, and to permit persons to whom the
# Software is furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
# DEALINGS IN THE SOFTWARE.

from __future__ import annotations
import asyncio
from logging import getLogger
import struct
from nextcore.http import HTTPClient # TODO: Replace with BaseHTTPClient
from typing import TYPE_CHECKING, Any
from nextcore.common import json_loads, json_dumps, Dispatcher
import time

from .udp_client import UDPClient

if TYPE_CHECKING:
from discord_typings import Snowflake
from aiohttp import ClientWebSocketResponse

__all__ = ("VoiceClient", )

_logger = getLogger(__name__)

class VoiceClient:
def __init__(self, guild_id: Snowflake, user_id: Snowflake, session_id: str, token: str, endpoint: str, http_client: HTTPClient) -> None:
self.guild_id: Snowflake = guild_id
self.user_id: Snowflake = user_id
self.session_id: str = session_id
self.token: str = token # TODO: Replace with Authentication?
self.endpoint: str = endpoint
self.ssrc: int | None = None
self.raw_dispatcher: Dispatcher[int] = Dispatcher()
self._http_client: HTTPClient = http_client
self._ws: ClientWebSocketResponse | None = None
self._socket: UDPClient | None = None

# Default event handlers
self.raw_dispatcher.add_listener(self._handle_hello, 8)
self.raw_dispatcher.add_listener(self._handle_ready, 2)

async def connect(self) -> None:
self._ws = await self._http_client.connect_to_voice_websocket(self.endpoint)
asyncio.create_task(self._receive_loop(self._ws))

async def send(self, message: Any) -> None:
if self._ws is None:
raise RuntimeError("Shame! Shame!") # TODO: Lol
_logger.debug("Send to websocket: %s", message)
await self._ws.send_json(message, dumps=json_dumps)


async def identify(self, guild_id: Snowflake, user_id: Snowflake, session_id: str, token: str) -> None:
await self.send({
"op": 0,
"d": {
"server_id": guild_id, # Why is this called server_id?
"user_id": user_id,
"session_id": session_id,
"token": token
}
})

async def heartbeat(self) -> None:
await self.send({
"op": 3,
"d": int(time.time()) # This should not be frequent enough that it becomes a issue.
})

async def _receive_loop(self, ws: ClientWebSocketResponse) -> None:
_logger.debug("Started listening for messages")
async for message in ws:
data = message.json(loads=json_loads) # TODO: Type hint!
_logger.debug("Received data from the websocket: %s", data)
await self.raw_dispatcher.dispatch(data["op"], data)
_logger.info("WebSocket closed with code %s!", ws.close_code)

async def _heartbeat_loop(self, ws: ClientWebSocketResponse, interval_seconds: float) -> None:
_logger.debug("Started heartbeating every %ss", interval_seconds)
while not ws.closed:
await self.heartbeat()
await asyncio.sleep(interval_seconds)

async def _handle_hello(self, event_data: dict[str, Any]) -> None:
assert self._ws is not None, "WebSocket was None in hello"

await self.identify(self.guild_id, self.user_id, self.session_id, self.token)
heartbeat_interval_ms = event_data["d"]["heartbeat_interval"]
heartbeat_interval_seconds = heartbeat_interval_ms / 1000

asyncio.create_task(self._heartbeat_loop(self._ws, heartbeat_interval_seconds))

async def _handle_ready(self, event: dict[str, Any]) -> None:
event_data = event["d"]
voice_ip = event_data["ip"]
voice_port = event_data["port"]
self.ssrc = event_data["ssrc"]

self._socket = UDPClient()
await self._socket.connect(voice_ip, voice_port)

# IP Discovery
HEADER_SIZE = 4
PAYLOAD_SIZE = 70
packet = bytearray(PAYLOAD_SIZE + HEADER_SIZE)
struct.pack_into(">HHI", packet, 0, 0x1, PAYLOAD_SIZE, self.ssrc)
await self._socket.send(packet)

response = await self._socket.socket.receive()

raw_ip, port = struct.unpack(">8x64sH", response)
ip = raw_ip.decode("ascii")
_logger.debug("Got public IP and port from discovery: %s:%s", ip, port)

asyncio.create_task(self._socket.receive_loop())

await self.send({"op": 1, "d": {
"protocol": "udp",
"data": {
"address": ip,
"port": port,
"mode": "xsalsa20_poly1305_lite"
}
}})





1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ typing-extensions = "^4.1.1" # Same as above
orjson = {version = "^3.6.8", optional = true}
types-orjson = {version = "^3.6.2", optional = true}
discord-typings = "^0.5.0"
anyio = "^3.6.2"

[tool.poetry.group.dev.dependencies]
Sphinx = "^5.0.0"
Expand Down