diff --git a/discord_http/channel.py b/discord_http/channel.py index e04ef34..d4522ac 100644 --- a/discord_http/channel.py +++ b/discord_http/channel.py @@ -5,7 +5,7 @@ from collections.abc import AsyncIterator, Callable, Generator from datetime import datetime, timedelta -from typing import TYPE_CHECKING, Self, overload, Literal +from typing import TYPE_CHECKING, Any, Self, overload, Literal from . import utils from .embeds import Embed @@ -32,6 +32,7 @@ from .member import ThreadMember from .message import PartialMessage, Message, Poll from .user import PartialUser, User + from .voice.client import VoiceClient MISSING = utils.MISSING @@ -48,6 +49,7 @@ "NewsThread", "PartialChannel", "PartialThread", + "PartialVoiceState", "PrivateThread", "PublicThread", "StageChannel", @@ -56,6 +58,7 @@ "Thread", "VoiceChannel", "VoiceRegion", + "VoiceState", ) @@ -478,6 +481,89 @@ async def create_invite( data=r.response ) + async def connect( + self, + *, + timeout: float = 30.0, + reconnect: bool = True, + reconnect_on_session_invalid: bool = False, + self_deaf: bool = False, + self_mute: bool = False + ) -> "VoiceClient": + """ + Connect to this voice channel. + + Parameters + ---------- + timeout: + How long to wait, in seconds, for the voice handshake to complete. + reconnect: + Whether to automatically reconnect if the voice connection drops. + reconnect_on_session_invalid: + What to do when Discord invalidates the voice session (close code + 4006), which most commonly happens when the channel empties out and + Discord tears down the DAVE/MLS session. By default (``False``) the + bot disconnects; set to ``True`` to attempt a full reconnect instead. + self_deaf: + Whether the bot should be self-deafened. + self_mute: + Whether the bot should be self-muted. + + Returns + ------- + The voice client for the connection. + + Raises + ------ + `TypeError` + If the channel is not a voice or stage channel. + `ValueError` + If the channel is not associated with a guild. + `NotImplementedError` + If the gateway is not available. + `RuntimeError` + If the bot is already connected to a voice channel in this guild. + """ + if self.type not in ( + ChannelType.unknown, + ChannelType.guild_voice, + ChannelType.guild_stage_voice + ): + raise TypeError("Cannot connect to a non-voice channel") + + if not self.guild_id: + raise ValueError("Cannot connect to a voice channel without a guild") + + client = self._state.bot + + if not client.gateway: + raise NotImplementedError("gateway is not available") + + if client.get_voice_client(self.guild_id) is not None: + raise RuntimeError("Already connected to a voice channel in this guild") + + from .voice.client import VoiceClient + + vc = VoiceClient(client, self) + client._add_voice_client(self.guild_id, vc) + try: + await vc.connect( + timeout=timeout, + reconnect=reconnect, + reconnect_on_session_invalid=reconnect_on_session_invalid, + self_deaf=self_deaf, + self_mute=self_mute + ) + except BaseException: + # Catch BaseException, not just Exception: a timeout or a cancelled + # connect (asyncio.CancelledError is a BaseException on 3.11+) must + # still remove the half-registered voice client, otherwise the stale + # entry blocks future connect attempts in this guild. + client._remove_voice_client(self.guild_id) + raise + + return vc + async def send( self, content: str | None = MISSING, @@ -2732,3 +2818,222 @@ async def create_stage_instance( guild=self.guild ) return self._stage_instance + + +class PartialVoiceState(PartialBase): + """ Represents a partial voice state object. """ + + __slots__ = ( + "_state", + "channel_id", + "guild_id", + ) + + def __init__( + self, + *, + state: "DiscordAPI", + id: int, # noqa: A002 + channel_id: int | None = None, + guild_id: int | None = None, + ): + self._state = state + + self.id: int = int(id) + """ The ID of the user this voice state belongs to. """ + + self.channel_id: int | None = channel_id + """ The ID of the voice channel this user is in, if any. """ + + self.guild_id: int | None = guild_id + """ The ID of the guild this voice state is in, if any. """ + + def __repr__(self) -> str: + return f"" + + def __str__(self) -> str: + return str(self.id) + + @property + def channel(self) -> "BaseChannel | PartialChannel | None": + """ + The voice channel this user is in, if any. + + Returns a `PartialChannel` built from `channel_id` so it can be used + directly (e.g. ``await voice_state.channel.connect()``) even when the + full channel object is not cached. + """ + if self.channel_id is None: + return None + return self._state.bot.get_partial_channel( + self.channel_id, guild_id=self.guild_id + ) + + async def fetch(self) -> "VoiceState": + """ + Fetches the voice state of the member. + + Returns + ------- + The voice state of the member + + Raises + ------ + `NotFound` + - If the member is not in the guild + - If the member is not in a voice channel + """ + if not self.guild_id: + raise ValueError("Cannot fetch voice state without guild_id") + + r = await self._state.query( + "GET", + f"/guilds/{self.guild_id}/voice-states/{self.id}" + ) + + guild = self._state.cache.get_guild(self.guild_id) + channel = None + channel_id = utils.get_int(r.response, "channel_id") + if channel_id is not None: + channel = self._state.cache.get_channel(self.guild_id, channel_id) + + return VoiceState( + state=self._state, + data=r.response, + guild=guild, + channel=channel + ) + + async def edit( + self, + *, + suppress: bool = MISSING, + ) -> None: + """ + Updates the voice state of the member. + + Parameters + ---------- + suppress: + Whether to suppress the user + """ + if not self.guild_id: + raise ValueError("Cannot update voice state without guild_id") + + data: dict[str, Any] = {} + + if suppress is not MISSING: + data["suppress"] = bool(suppress) + + await self._state.query( + "PATCH", + f"/guilds/{self.guild_id}/voice-states/{int(self.id)}", + json=data, + res_method="text" + ) + + +class VoiceState(PartialVoiceState): + """ Represents a voice state object. """ + + __slots__ = ( + "_channel", + "deaf", + "guild", + "member", + "mute", + "request_to_speak_timestamp", + "self_deaf", + "self_mute", + "self_stream", + "self_video", + "session_id", + "suppress", + "user", + ) + + def __init__( + self, + *, + state: "DiscordAPI", + data: dict, + guild: "PartialGuild | None", + channel: "BaseChannel | PartialChannel | None" + ): + from .user import PartialUser + + super().__init__( + state=state, + id=int(data["user_id"]), + guild_id=utils.get_int(data, "guild_id"), + channel_id=utils.get_int(data, "channel_id") + ) + + self.session_id: str = data["session_id"] + """ The session ID of the voice state. """ + + self.user: PartialUser = PartialUser(state=state, id=int(data["user_id"])) + """ The user this voice state belongs to. """ + + self.member: "Member | None" = None + """ The member this voice state belongs to, if any. """ + + self._channel: "BaseChannel | PartialChannel | None" = channel + + self.guild: "PartialGuild | None" = guild + """ The guild this voice state is in, if any. """ + + self.deaf: bool = data["deaf"] + """ Whether the user is deafened by the server. """ + + self.mute: bool = data["mute"] + """ Whether the user is muted by the server. """ + + self.self_deaf: bool = data["self_deaf"] + """ Whether the user is deafened by themselves. """ + + self.self_mute: bool = data["self_mute"] + """ Whether the user is muted by themselves. """ + + self.self_stream: bool = data.get("self_stream", False) + """ Whether the user is streaming. """ + + self.self_video: bool = data["self_video"] + """ Whether the user is using video. """ + + self.suppress: bool = data["suppress"] + """ Whether the user is suppressed by the server. """ + + self.request_to_speak_timestamp: datetime | None = None + """ The timestamp when the user requested to speak, if any. """ + + self._from_data(data) + + def __repr__(self) -> str: + return f"" + + @property + def channel(self) -> "BaseChannel | PartialChannel | None": + """ + The voice channel this user is in, if any. + + Prefers the resolved channel object (when cached); otherwise falls back + to a `PartialChannel` built from `channel_id` so it remains usable. + """ + if self._channel is not None: + return self._channel + return super().channel + + def _from_data(self, data: dict) -> None: + if data.get("member") and self.guild: + from .member import Member + self.member = Member( + state=self._state, + guild=self.guild, + data=data["member"] + ) + + if data.get("request_to_speak_timestamp"): + self.request_to_speak_timestamp = utils.parse_time( + data["request_to_speak_timestamp"] + ) diff --git a/discord_http/client.py b/discord_http/client.py index aecb043..ef0bd54 100644 --- a/discord_http/client.py +++ b/discord_http/client.py @@ -13,7 +13,7 @@ from . import utils, __version__ from .automod import PartialAutoModRule, AutoModRule from .backend import DiscordHTTP -from .channel import PartialChannel, BaseChannel +from .channel import PartialChannel, BaseChannel, PartialVoiceState, VoiceState from .commands import Command, Interaction, Listener, Cog, SubGroup from .context import Context from .emoji import PartialEmoji, Emoji @@ -34,13 +34,13 @@ from .sticker import PartialSticker, Sticker from .user import User, PartialUser, Application from .view import InteractionStorage -from .voice import PartialVoiceState, VoiceState from .webhook import PartialWebhook, Webhook if TYPE_CHECKING: from .gateway.client import GatewayClient from .gateway.flags import GatewayCacheFlags, Intents from .gateway.object import PlayingStatus + from .voice.client import VoiceClient _log = logging.getLogger(__name__) @@ -102,6 +102,15 @@ class Client: Whether to disable the default GET path or not, if not provided, it will use `False`. The default GET path only provides information about the bot and when it was last rebooted. Usually a great tool to just validate that your bot is online. + voice_reconnect_attempts: int + How many times a voice connection will try to fully reconnect after an + unexpected close before giving up, if not provided, it will use `5`. + voice_reconnect_base: float + The base delay, in seconds, for the voice reconnect exponential backoff, + if not provided, it will use `1.0`. + voice_reconnect_max_delay: float + The maximum delay, in seconds, for the voice reconnect exponential + backoff, if not provided, it will use `30.0`. """ def __init__( self, @@ -126,7 +135,10 @@ def __init__( intents: "Intents | None" = None, logging_level: int = logging.INFO, disable_default_get_path: bool = False, - debug_events: bool = False + debug_events: bool = False, + voice_reconnect_attempts: int = 5, + voice_reconnect_base: float = 1.0, + voice_reconnect_max_delay: float = 30.0 ): if application_id is not None: _log.warning( @@ -149,6 +161,21 @@ def __init__( self.logging_level: int = logging_level self.debug_events: bool = debug_events self.enable_gateway: bool = enable_gateway + if voice_reconnect_attempts < 0: + raise ValueError("voice_reconnect_attempts must be >= 0") + self.voice_reconnect_attempts: int = voice_reconnect_attempts + """ + How many times a voice connection will try to fully reconnect after an + unexpected close before giving up. + """ + if voice_reconnect_base <= 0: + raise ValueError("voice_reconnect_base must be > 0") + self.voice_reconnect_base: float = voice_reconnect_base + """ The base delay, in seconds, for the voice reconnect exponential backoff. """ + if voice_reconnect_max_delay <= 0: + raise ValueError("voice_reconnect_max_delay must be > 0") + self.voice_reconnect_max_delay: float = voice_reconnect_max_delay + """ The maximum delay, in seconds, for the voice reconnect exponential backoff. """ self.playing_status: "PlayingStatus | None" = playing_status self.guild_ready_timeout: float = guild_ready_timeout self.chunk_guilds_on_startup: bool = chunk_guilds_on_startup @@ -204,6 +231,7 @@ def __init__( self._after_invoke: tuple[Callable, bool] | None = None self._waiting_listeners: dict[str, list[tuple[asyncio.Future, Callable]]] = {} self._background_tasks: set[asyncio.Task] = set() + self._voice_clients: dict[int, "VoiceClient"] = {} utils.setup_logger(level=self.logging_level) @@ -216,6 +244,45 @@ def _cleanup_task(self, task: asyncio.Task) -> None: except Exception: pass + def get_voice_client(self, guild_id: int) -> "VoiceClient | None": + """ + Get the voice client for a guild, if one is registered. + + Parameters + ---------- + guild_id: + The guild to get the voice client for. + + Returns + ------- + The voice client, or ``None`` if none is registered. + """ + return self._voice_clients.get(guild_id) + + def _add_voice_client(self, guild_id: int, voice_client: "VoiceClient") -> None: + """ + Register a voice client for a guild. + + Parameters + ---------- + guild_id: + The guild to register the voice client for. + voice_client: + The voice client to register. + """ + self._voice_clients[guild_id] = voice_client + + def _remove_voice_client(self, guild_id: int) -> None: + """ + Remove the voice client for a guild, if one is registered. + + Parameters + ---------- + guild_id: + The guild to remove the voice client for. + """ + self._voice_clients.pop(guild_id, None) + async def _cooldown_cleanup_loop(self) -> None: """ Periodically sweeps expired cooldown buckets that accumulate between invocations. """ while True: @@ -514,6 +581,11 @@ def user(self) -> User: return self.application.bot + @property + def voice_clients(self) -> list["VoiceClient"]: + """ A list of all the voice clients the bot is connected to. """ + return list(self._voice_clients.values()) + @property def guilds(self) -> list[Guild | PartialGuild]: """ diff --git a/discord_http/errors.py b/discord_http/errors.py index dcf469e..409c82d 100644 --- a/discord_http/errors.py +++ b/discord_http/errors.py @@ -20,6 +20,8 @@ "HTTPException", "InvalidMember", "NotFound", + "OpusError", + "OpusNotLoaded", "Ratelimited", "UserMissingPermissions", ) @@ -29,6 +31,14 @@ class DiscordException(Exception): # noqa: N818 """ Base exception for discord_http. """ +class OpusError(DiscordException): + """ Raised when libopus returns an error code. """ + + +class OpusNotLoaded(DiscordException): + """ Raised when an Opus operation is attempted but libopus is not available. """ + + class CheckFailed(DiscordException): """ Raised whenever a check fails. """ diff --git a/discord_http/gateway/cache.py b/discord_http/gateway/cache.py index aa35911..abed2c1 100644 --- a/discord_http/gateway/cache.py +++ b/discord_http/gateway/cache.py @@ -1,7 +1,6 @@ from typing import TYPE_CHECKING -from ..channel import BaseChannel -from ..voice import VoiceState, PartialVoiceState +from ..channel import BaseChannel, PartialVoiceState, VoiceState from .flags import GatewayCacheFlags diff --git a/discord_http/gateway/client.py b/discord_http/gateway/client.py index 0b59067..4b64181 100644 --- a/discord_http/gateway/client.py +++ b/discord_http/gateway/client.py @@ -234,6 +234,22 @@ def start(self) -> None: async def close(self) -> None: """ Close the gateway client. """ + # Tear down any active voice connections first. Each voice websocket is a + # separate connection to Discord; once the gateway drops, Discord closes + # them abnormally (code 1006) and the voice socket would otherwise try to + # reconnect mid-shutdown. ``_cleanup`` closes them locally (no op4) and + # marks them as intentionally closing so no reconnect is scheduled. + for vc in list(self.bot._voice_clients.values()): + try: + await vc._cleanup() + except BaseException as exc: + # Catch BaseException, not just Exception: if close() is cancelled + # mid-loop (asyncio.CancelledError is a BaseException on 3.11+), + # the remaining voice clients and the shard closes below must + # still run to completion. Swallowing the cancellation here is + # deliberate; do not narrow this back to ``Exception``. + _log.debug("Error cleaning up voice client during shutdown", exc_info=exc) + to_close = [ asyncio.ensure_future(shard.close(kill=True)) for shard in self.__shards.values() diff --git a/discord_http/gateway/parser.py b/discord_http/gateway/parser.py index bc6e46d..f216006 100644 --- a/discord_http/gateway/parser.py +++ b/discord_http/gateway/parser.py @@ -7,7 +7,10 @@ from .. import utils from ..audit import AuditLogEntry from ..automod import AutoModRule -from ..channel import BaseChannel, PartialChannel, StageInstance, PartialThread +from ..channel import ( + BaseChannel, PartialChannel, PartialThread, + PartialVoiceState, StageInstance, VoiceState +) from ..emoji import Emoji, EmojiParser from ..entitlements import Entitlements from ..enums import ChannelType @@ -20,7 +23,6 @@ from ..soundboard import PartialSoundboardSound, SoundboardSound from ..sticker import Sticker from ..user import User, PartialUser -from ..voice import VoiceState, PartialVoiceState from .enums import PollVoteActionType from .flags import GatewayCacheFlags @@ -1336,16 +1338,20 @@ def invite_delete(self, data: dict) -> tuple[PartialInvite]: ), ) - """ - This is just a placeholder for now. - I am unsure if I ever will handle voice communication with discord.http/gateway - Let this be a reminder for myself in later time + def voice_server_update(self, data: dict) -> tuple[dict]: + """ + Voice server update event. - - AlexFlipnote, 9. October 2024 + Parameters + ---------- + data: + Data received from the event. - def voice_channel_effect_send(self, data: dict) -> tuple[None]: - return (None,) - """ + Returns + ------- + The raw voice server update payload. + """ + return (data,) def voice_state_update(self, data: dict) -> tuple[ VoiceState | PartialVoiceState | None, @@ -1385,6 +1391,7 @@ def voice_state_update(self, data: dict) -> tuple[ ) self.bot.cache.update_voice_state(vs) + return (before_vs, vs) def typing_start(self, data: dict) -> tuple[TypingStartEvent]: diff --git a/discord_http/gateway/shard.py b/discord_http/gateway/shard.py index 26383d9..033c142 100644 --- a/discord_http/gateway/shard.py +++ b/discord_http/gateway/shard.py @@ -352,6 +352,8 @@ def __init__( "GUILD_CREATE": (self._parse_guild_create, True), "GUILD_DELETE": (self._parse_guild_delete, False), "GUILD_MEMBERS_CHUNK": (self._parse_guild_members_chunk, True), + "VOICE_STATE_UPDATE": (self._parse_voice_state_update, False), + "VOICE_SERVER_UPDATE": (self._parse_voice_server_update, False), } @property @@ -1058,6 +1060,32 @@ def _parse_guild_delete(self, data: dict) -> None: self._send_dispatch(event_name, guild) + def _parse_voice_state_update(self, data: dict) -> None: + payload = self.parser.voice_state_update(data) + + bot_user = self.bot.application.bot if self.bot.application else None + if ( + bot_user is not None + and data.get("guild_id") is not None + and int(data["user_id"]) == bot_user.id + ): + vc = self.bot.get_voice_client(int(data["guild_id"])) + if vc is not None: + vc.on_voice_state_update(data) + + if self.bot.has_any_dispatch("voice_state_update"): + self._send_dispatch("voice_state_update", *payload) + + def _parse_voice_server_update(self, data: dict) -> None: + (payload,) = self.parser.voice_server_update(data) + + vc = self.bot.get_voice_client(int(data["guild_id"])) + if vc is not None: + vc.on_voice_server_update(data) + + if self.bot.has_any_dispatch("voice_server_update"): + self._send_dispatch("voice_server_update", payload) + async def _parse_guild_members_chunk(self, data: dict) -> None: result = self.parser.guild_members_chunk(data) @@ -1086,6 +1114,39 @@ async def change_presence(self, status: PlayingStatus) -> None: "d": status.to_dict() }) + async def change_voice_state( + self, + *, + guild_id: int, + channel_id: int | None, + self_mute: bool = False, + self_deaf: bool = False + ) -> None: + """ + Changes the voice state of the shard for the specified guild. + + Parameters + ---------- + guild_id: + The guild to change the voice state in. + channel_id: + The voice channel to connect to, or ``None`` to disconnect. + self_mute: + Whether the bot is self-muted. + self_deaf: + Whether the bot is self-deafened. + """ + _log.debug(f"Changing voice state in Shard {self.shard_id} for guild {guild_id} to channel {channel_id}") + await self.send_message({ + "op": int(PayloadType.voice_state), + "d": { + "guild_id": str(guild_id), + "channel_id": str(channel_id) if channel_id is not None else None, + "self_mute": bool(self_mute), + "self_deaf": bool(self_deaf) + } + }) + def payload(self, op: PayloadType) -> dict: """ Returns a payload for the websocket. diff --git a/discord_http/guild.py b/discord_http/guild.py index 1cd2623..b2ee3d6 100644 --- a/discord_http/guild.py +++ b/discord_http/guild.py @@ -29,7 +29,7 @@ from .message import Message from .soundboard import SoundboardSound, PartialSoundboardSound from .sticker import Sticker, PartialSticker -from .voice import VoiceState, PartialVoiceState +from .channel import VoiceState, PartialVoiceState if TYPE_CHECKING: from .audit import AuditLogEntry diff --git a/discord_http/utils.py b/discord_http/utils.py index 47989f0..89db2d7 100644 --- a/discord_http/utils.py +++ b/discord_http/utils.py @@ -3,6 +3,7 @@ import logging import orjson import posixpath +import random import re import struct import sys @@ -294,6 +295,60 @@ def to_dict(self) -> dict[str, float]: } +class ExponentialBackoff: + """ + A small helper that produces exponentially increasing delays. + + Each call to :meth:`delay` returns ``base * 2 ** exp`` (capped at + ``max_delay``), incrementing an internal exponent so successive calls grow + geometrically. Optional jitter spreads retries out to avoid thundering-herd + reconnect storms. Calling :meth:`reset` returns the backoff to its initial + state, e.g. after a successful reconnect. + + Parameters + ---------- + base: + The base delay, in seconds, used as the multiplier for the exponent. + max_delay: + The maximum delay, in seconds, that any single call may return. + jitter: + Whether to apply random jitter to each returned delay. + """ + + def __init__(self, base: float = 1.0, *, max_delay: float = 60.0, jitter: bool = True): + self.base: float = base + """ The base delay, in seconds. """ + + self.max_delay: float = max_delay + """ The maximum delay, in seconds, returned by :meth:`delay`. """ + + self.jitter: bool = jitter + """ Whether random jitter is applied to each delay. """ + + self._exp: int = 0 + + def delay(self) -> float: + """ + Return the next backoff delay and advance the internal exponent. + + Returns + ------- + The next delay, in seconds, capped at :attr:`max_delay` and + optionally jittered. + """ + self._exp += 1 + value = min(self.base * (2 ** (self._exp - 1)), self.max_delay) + + if self.jitter: + value *= random.uniform(0.5, 1.0) + + return value + + def reset(self) -> None: + """ Reset the internal exponent so the next delay starts from the base. """ + self._exp = 0 + + def format_small_unit(seconds: float | timedelta) -> str: """ Helper to scale sub-second values to the appropriate unit. diff --git a/discord_http/voice.py b/discord_http/voice.py deleted file mode 100644 index 733a947..0000000 --- a/discord_http/voice.py +++ /dev/null @@ -1,209 +0,0 @@ -from datetime import datetime -from typing import TYPE_CHECKING, Any - -from . import utils -from .object import PartialBase -from .user import PartialUser - -MISSING = utils.MISSING - -if TYPE_CHECKING: - from .channel import BaseChannel, PartialChannel - from .guild import PartialGuild - from .http import DiscordAPI - from .member import Member - -__all__ = ( - "PartialVoiceState", - "VoiceState", -) - - -class PartialVoiceState(PartialBase): - """ Represents a partial voice state object. """ - - __slots__ = ( - "_state", - "channel_id", - "guild_id", - ) - - def __init__( - self, - *, - state: "DiscordAPI", - id: int, # noqa: A002 - channel_id: int | None = None, - guild_id: int | None = None, - ): - self._state = state - - self.id: int = int(id) - """ The ID of the user this voice state belongs to. """ - - self.channel_id: int | None = channel_id - """ The ID of the voice channel this user is in, if any. """ - - self.guild_id: int | None = guild_id - """ The ID of the guild this voice state is in, if any. """ - - def __repr__(self) -> str: - return f"" - - def __str__(self) -> str: - return "PartialVoiceState" - - async def fetch(self) -> "VoiceState": - """ - Fetches the voice state of the member. - - Returns - ------- - The voice state of the member - - Raises - ------ - `NotFound` - - If the member is not in the guild - - If the member is not in a voice channel - """ - if not self.guild_id: - raise ValueError("Cannot fetch voice state without guild_id") - - r = await self._state.query( - "GET", - f"/guilds/{self.guild_id}/voice-states/{self.id}" - ) - - guild = self._state.cache.get_guild(self.guild_id) - channel = None - if self.channel_id is not None: - channel = self._state.cache.get_channel(self.guild_id, self.channel_id) - - return VoiceState( - state=self._state, - data=r.response, - guild=guild, - channel=channel - ) - - async def edit( - self, - *, - suppress: bool = MISSING, - ) -> None: - """ - Updates the voice state of the member. - - Parameters - ---------- - suppress: - Whether to suppress the user - """ - if not self.guild_id: - raise ValueError("Cannot update voice state without guild_id") - - data: dict[str, Any] = {} - - if suppress is not MISSING: - data["suppress"] = bool(suppress) - - await self._state.query( - "PATCH", - f"/guilds/{self.guild_id}/voice-states/{int(self.id)}", - json=data, - res_method="text" - ) - - -class VoiceState(PartialVoiceState): - """ Represents a voice state object. """ - - __slots__ = ( - "channel", - "deaf", - "guild", - "member", - "mute", - "request_to_speak_timestamp", - "self_deaf", - "self_mute", - "self_stream", - "self_video", - "session_id", - "suppress", - "user", - ) - - def __init__( - self, - *, - state: "DiscordAPI", - data: dict, - guild: "PartialGuild | None", - channel: "BaseChannel | PartialChannel | None" - ): - super().__init__( - state=state, - id=int(data["user_id"]), - guild_id=utils.get_int(data, "guild_id"), - channel_id=utils.get_int(data, "channel_id") - ) - - self.session_id: str = data["session_id"] - """ The session ID of the voice state. """ - - self.user: PartialUser = PartialUser(state=state, id=int(data["user_id"])) - """ The user this voice state belongs to. """ - - self.member: "Member | None" = None - """ The member this voice state belongs to, if any. """ - - self.channel: "BaseChannel | PartialChannel | None" = channel - """ The voice channel this user is in, if any. """ - - self.guild: "PartialGuild | None" = guild - """ The guild this voice state is in, if any. """ - - self.deaf: bool = data["deaf"] - """ Whether the user is deafened by the server. """ - - self.mute: bool = data["mute"] - """ Whether the user is muted by the server. """ - - self.self_deaf: bool = data["self_deaf"] - """ Whether the user is deafened by themselves. """ - - self.self_mute: bool = data["self_mute"] - """ Whether the user is muted by themselves. """ - - self.self_stream: bool = data.get("self_stream", False) - """ Whether the user is streaming. """ - - self.self_video: bool = data["self_video"] - """ Whether the user is using video. """ - - self.suppress: bool = data["suppress"] - """ Whether the user is suppressed by the server. """ - - self.request_to_speak_timestamp: datetime | None = None - """ The timestamp when the user requested to speak, if any. """ - - self._from_data(data) - - def __repr__(self) -> str: - return f"" - - def _from_data(self, data: dict) -> None: - if data.get("member") and self.guild: - from .member import Member - self.member = Member( - state=self._state, - guild=self.guild, - data=data["member"] - ) - - if data.get("request_to_speak_timestamp"): - self.request_to_speak_timestamp = utils.parse_time( - data["request_to_speak_timestamp"] - ) diff --git a/discord_http/voice/__init__.py b/discord_http/voice/__init__.py new file mode 100644 index 0000000..62d40a7 --- /dev/null +++ b/discord_http/voice/__init__.py @@ -0,0 +1,10 @@ +# ruff: noqa: F401, F403 +from . import opus +from .client import * +from .connection import * +from .dave import * +from .enums import * +from .opus import * +from .player import * +from .receiver import * +from .sinks import * diff --git a/discord_http/voice/client.py b/discord_http/voice/client.py new file mode 100644 index 0000000..454a722 --- /dev/null +++ b/discord_http/voice/client.py @@ -0,0 +1,327 @@ +import asyncio +import logging +import struct + +from collections.abc import Callable +from typing import TYPE_CHECKING + +from .connection import VoiceConnection + +if TYPE_CHECKING: + from ..channel import PartialChannel + from ..client import Client + from .opus import Encoder + from .player import AudioPlayer, AudioSourceInput + from .receiver import VoiceReceiver + from .sinks import AudioSink + +__all__ = ("VoiceClient",) + +_log = logging.getLogger(__name__) + + +class VoiceClient: + """ The public handle for an active voice connection in a guild. """ + + def __init__(self, client: "Client", channel: "PartialChannel"): + self.bot: "Client" = client + """ The bot client that owns this voice client. """ + + self.channel: "PartialChannel" = channel + """ The voice channel this client is connected to. """ + + if channel.guild_id is None: + raise ValueError("Cannot create a voice client for a channel without a guild") + + self.guild_id: int = channel.guild_id + """ The ID of the guild this voice client is in. """ + + self.connection: VoiceConnection = VoiceConnection(self) + """ The underlying voice connection state machine. """ + + self._player: "AudioPlayer | None" = None + self._encoder: "Encoder | None" = None + + # Built eagerly rather than in listen(): Discord only sends SPEAKING on + # the leading edge of speech, so a receiver created later would miss the + # SSRC mapping for anyone already talking, and they would stay unmapped + # until they stopped and started again. It allocates nothing until a + # sink is attached, and unpack() returns early while sink is None. + from .receiver import VoiceReceiver + self._receiver: "VoiceReceiver" = VoiceReceiver(self) + + @property + def loop(self) -> asyncio.AbstractEventLoop: + """ The event loop the client runs on. """ + return self.bot.loop + + @property + def user_id(self) -> int: + """ The ID of the bot user. """ + return self.bot.user.id + + @property + def ssrc(self) -> int | None: + """ The SSRC assigned to this connection. """ + return self.connection.ssrc + + @property + def secret_key(self) -> bytes | None: + """ The transport secret key, once known. """ + return self.connection.secret_key + + @property + def latency(self) -> float: + """ The latency of the most recent voice heartbeat, in seconds. """ + return self.connection.latency + + @property + def average_latency(self) -> float: + """ The average latency of recent voice heartbeats, in seconds. """ + return self.connection.average_latency + + @property + def voice_privacy_code(self) -> str | None: + """ The DAVE voice privacy code, if available. """ + return self.connection.voice_privacy_code + + @property + def endpoint(self) -> str | None: + """ The voice server endpoint host. """ + return self.connection.endpoint + + @property + def session_id(self) -> str | None: + """ The voice session ID. """ + return self.connection.session_id + + def is_connected(self) -> bool: + """ Whether the voice connection is established. """ + return self.connection.is_connected() + + async def connect( + self, + *, + timeout: float = 30.0, + reconnect: bool = True, + reconnect_on_session_invalid: bool = False, + self_deaf: bool = False, + self_mute: bool = False + ) -> None: + """ + Connect to the voice channel. + + Parameters + ---------- + timeout: + The maximum time to wait for the handshake, in seconds. + reconnect: + Whether to attempt reconnection on failure. + reconnect_on_session_invalid: + Whether to reconnect when Discord invalidates the session (close code + 4006), e.g. after the channel empties and the DAVE session is torn + down. Defaults to ``False`` (disconnect instead of reconnecting). + self_deaf: + Whether to join self-deafened. + self_mute: + Whether to join self-muted. + """ + await self.connection.connect( + timeout=timeout, + reconnect=reconnect, + reconnect_on_session_invalid=reconnect_on_session_invalid, + self_deaf=self_deaf, + self_mute=self_mute, + ) + + def _stop_media(self) -> None: + """ Stop and discard the player, receiver, and encoder. """ + if self._player is not None: + self._player.stop() + self._player = None + + self._receiver.stop() + + if self._encoder is not None: + self._encoder.cleanup() + self._encoder = None + + async def disconnect(self, *, force: bool = True) -> None: + """ + Disconnect from the voice channel and clean up. + + Parameters + ---------- + force: + Whether to force the disconnect even on error. + """ + self._stop_media() + await self.connection.disconnect(force=force) + self.bot._remove_voice_client(self.guild_id) + + async def _cleanup(self) -> None: + """ Tear down the voice client locally without relying on the gateway. """ + self._stop_media() + await self.connection.close_transport() + self.bot._remove_voice_client(self.guild_id) + + async def move_to(self, channel: "PartialChannel | int") -> None: + """ + Move to a different voice channel. + + Parameters + ---------- + channel: + The channel to move to, either a channel object or its ID. + """ + if isinstance(channel, int): + channel = self.bot.get_partial_channel(channel, guild_id=self.guild_id) + await self.connection.move_to(channel) + self.channel = channel + + def on_voice_state_update(self, data: dict) -> None: + """ + Forward a VOICE_STATE_UPDATE to the connection. + + Parameters + ---------- + data: + The raw voice state update payload. + """ + self.connection.on_voice_state_update(data) + + def on_voice_server_update(self, data: dict) -> None: + """ + Forward a VOICE_SERVER_UPDATE to the connection. + + Parameters + ---------- + data: + The raw voice server update payload. + """ + self.connection.on_voice_server_update(data) + + async def speak(self, speaking: bool = True) -> None: + """ + Send the SPEAKING frame to the voice gateway. + + Parameters + ---------- + speaking: + Whether the bot is speaking. + """ + if self.connection.socket is None or self.connection.ssrc is None: + return + await self.connection.socket.send_speaking(1 if speaking else 0, ssrc=self.connection.ssrc) + + def _get_encoder(self) -> "Encoder": + """ Return the cached Opus encoder, creating it on first use. """ + if self._encoder is None: + from .opus import Encoder + + self._encoder = Encoder() + return self._encoder + + def send_audio_packet(self, data: bytes, *, encode: bool = True) -> None: + """ + Frame, encrypt, and transmit a single audio packet over UDP. + + Parameters + ---------- + data: + The audio payload: PCM when ``encode`` is ``True`` else a raw Opus packet. + encode: + Whether ``data`` is PCM that must be Opus-encoded first. + """ + connection = self.connection + if connection.encryptor is None or connection.transport is None or connection.ssrc is None: + return + + opus = self._get_encoder().encode(data) if encode else data + + # DAVE end-to-end encryption applies to the Opus payload before RTP framing. + if connection.can_encrypt(): + opus = connection.dave_encrypt_opus(opus) + + connection.sequence = (connection.sequence + 1) % (2 ** 16) + + header = bytearray(12) + struct.pack_into(">BBHII", header, 0, 0x80, 0x78, connection.sequence, connection.timestamp, connection.ssrc) + + connection.timestamp = (connection.timestamp + 960) % (2 ** 32) + + packet = connection.encryptor.encrypt(bytes(header), opus) + + try: + connection.transport.sendto(packet) + except OSError: + _log.debug(f"Failed to send audio packet for guild {self.guild_id}") + + def play( + self, + audio: "AudioSourceInput", + *, + after: Callable[[Exception | None], object] | None = None + ) -> None: + """ + Play an audio source over the connection. + + Parameters + ---------- + audio: + The audio source, path, bytes, or stream to play. + after: + A callback invoked with any error once playback finishes. + """ + from .player import AudioPlayer, _resolve_source + + if self._player is not None: + self._player.stop() + + source = _resolve_source(audio) + player = AudioPlayer(source, self, after=after) + self._player = player + player.start() + + def pause(self) -> None: + """ Pause the current playback. """ + if self._player is not None: + self._player.pause() + + def resume(self) -> None: + """ Resume paused playback. """ + if self._player is not None: + self._player.resume() + + def stop(self) -> None: + """ Stop the current playback. """ + if self._player is not None: + self._player.stop() + self._player = None + + def is_playing(self) -> bool: + """ Whether audio is currently playing. """ + return self._player is not None and self._player.is_playing() + + def is_paused(self) -> bool: + """ Whether playback is currently paused. """ + return self._player is not None and self._player.is_paused() + + def listen(self, sink: "AudioSink") -> None: + """ + Start receiving voice into the given sink. + + Parameters + ---------- + sink: + The audio sink to write received audio into. + """ + self._receiver.start(sink) + + def stop_listening(self) -> None: + """ Stop receiving voice. """ + self._receiver.stop() + + def is_listening(self) -> bool: + """ Whether the client is currently receiving voice. """ + return self._receiver.is_listening() diff --git a/discord_http/voice/connection.py b/discord_http/voice/connection.py new file mode 100644 index 0000000..fec2eff --- /dev/null +++ b/discord_http/voice/connection.py @@ -0,0 +1,946 @@ +import asyncio +import logging + +from typing import TYPE_CHECKING + +from ..utils import URL, ExponentialBackoff +from .dave import DaveManager, has_dave +from .encryptor import Encryptor +from .enums import SUPPORTED_MODES +from .gateway_udp import VoiceUDPProtocol, create_udp +from .socket import VoiceCloseCode, VoiceSocket + +if TYPE_CHECKING: + from ..channel import PartialChannel + from ..client import Client + from ..gateway.shard import Shard + from .client import VoiceClient + +__all__ = ("VoiceConnection",) + +_log = logging.getLogger(__name__) + + +class VoiceConnection: + """ The transport and control-plane state machine for a voice connection. """ + + def __init__(self, voice_client: "VoiceClient"): + self.voice_client: "VoiceClient" = voice_client + """ The voice client that owns this connection. """ + + self.guild_id: int = voice_client.guild_id + """ The ID of the guild this connection is for. """ + + self.channel_id: int | None = voice_client.channel.id + """ The ID of the voice channel currently targeted. """ + + self.user_id: int = voice_client.user_id + """ The ID of the bot user. """ + + self.socket: VoiceSocket | None = None + """ The voice websocket, if open. """ + + self.udp: VoiceUDPProtocol | None = None + """ The UDP protocol, if connected. """ + + self.transport: asyncio.DatagramTransport | None = None + """ The UDP datagram transport, if connected. """ + + self.encryptor: Encryptor | None = None + """ The transport encryptor, once the secret key is known. """ + + self.token: str | None = None + """ The voice connection token from the voice server update. """ + + self.endpoint: str | None = None + """ The voice server endpoint host, without scheme or port. """ + + self.session_id: str | None = None + """ The voice session ID from the voice state update. """ + + self.server_id: int | None = None + """ The server (guild) ID from the voice server update. """ + + self.ssrc: int | None = None + """ The synchronisation source identifier assigned by the gateway. """ + + self.secret_key: bytes | None = None + """ The secret key used for transport encryption. """ + + self.endpoint_ip: str | None = None + """ The discovered external IP address. """ + + self.endpoint_port: int | None = None + """ The discovered external UDP port. """ + + self.mode: str | None = None + """ The negotiated encryption mode. """ + + self.sequence: int = 0 + """ The RTP sequence counter. """ + + self.timestamp: int = 0 + """ The RTP timestamp counter. """ + + self.dave_session: "DaveManager | None" = None + """ The DAVE/MLS session manager, if a protocol version was negotiated. """ + + self.dave_protocol_version: int = 0 + """ The negotiated DAVE protocol version (0 if not in use). """ + + self._state_event: asyncio.Event = asyncio.Event() + self._server_event: asyncio.Event = asyncio.Event() + self._ready_event: asyncio.Event = asyncio.Event() + self._connected_event: asyncio.Event = asyncio.Event() + # Set when the gateway acknowledges the bot leaving the channel + # (VOICE_STATE_UPDATE with channel_id=None); used by the reconnect + # bounce to confirm the leave before rejoining. + self._left_event: asyncio.Event = asyncio.Event() + + self._reconnect: bool = True + self._reconnect_on_session_invalid: bool = False + self._self_mute: bool = False + self._self_deaf: bool = False + self._closing: bool = False + self._reconnect_task: asyncio.Task | None = None + # Channel moves yield both VOICE_STATE_UPDATE and VOICE_SERVER_UPDATE. + # Keep the requested target until both have arrived so a replacement + # voice session never identifies with the previous channel's session ID. + self._move_target_channel_id: int | None = None + self._move_server_update_received = False + # Built lazily on first use so the backoff base/max_delay can be read + # from the client (mirroring how voice_reconnect_attempts is read). + self._backoff: ExponentialBackoff | None = None + + @property + def backoff(self) -> ExponentialBackoff: + """ The exponential backoff for reconnects, built lazily from client settings. """ + if self._backoff is None: + self._backoff = ExponentialBackoff( + base=self.client.voice_reconnect_base, + max_delay=self.client.voice_reconnect_max_delay, + ) + return self._backoff + + @property + def client(self) -> "Client": + """ The bot client that owns this voice connection. """ + return self.voice_client.bot + + @property + def latency(self) -> float: + """ The latency of the most recent voice heartbeat, in seconds. """ + if self.socket is None: + return float("inf") + return self.socket.latency + + @property + def average_latency(self) -> float: + """ The average latency of recent voice heartbeats, in seconds. """ + if self.socket is None: + return float("inf") + return self.socket.average_latency + + @property + def voice_privacy_code(self) -> str | None: + """ The DAVE voice privacy code, if a DAVE session is active. """ + if self.dave_session is None: + return None + return self.dave_session.voice_privacy_code + + def is_connected(self) -> bool: + """ Whether the connection has completed its handshake. """ + return self._connected_event.is_set() + + def _get_shard(self) -> "Shard | None": + """ Resolve the gateway shard that owns this connection's guild, if any. """ + client = self.client + shard_id = client.get_shard_by_guild_id(self.guild_id) + if client.gateway is None or shard_id is None: + return None + return client.gateway.get_shard(shard_id) + + async def connect( + self, + *, + timeout: float = 30.0, + reconnect: bool = True, + reconnect_on_session_invalid: bool = False, + self_deaf: bool = False, + self_mute: bool = False + ) -> None: + """ + Establish the full voice connection. + + Parameters + ---------- + timeout: + The maximum time to wait for the handshake, in seconds. + reconnect: + Whether to attempt reconnection on failure. + reconnect_on_session_invalid: + Whether to reconnect when Discord invalidates the session (close code + 4006), e.g. after the channel empties and the DAVE session is torn + down. Defaults to ``False`` (disconnect instead of reconnecting). + self_deaf: + Whether to join self-deafened. + self_mute: + Whether to join self-muted. + + Raises + ------ + RuntimeError + If no shard can be resolved for the guild. + TimeoutError + If the handshake does not complete within ``timeout``. + """ + await self._connect( + timeout=timeout, + reconnect=reconnect, + reconnect_on_session_invalid=reconnect_on_session_invalid, + self_deaf=self_deaf, + self_mute=self_mute, + ) + + async def _connect( + self, + *, + timeout: float = 30.0, + reconnect: bool = True, + reconnect_on_session_invalid: bool = False, + self_deaf: bool = False, + self_mute: bool = False, + preserve_backoff: bool = False + ) -> None: + """ + Drive the full voice connection handshake. + + Parameters + ---------- + timeout: + The maximum time to wait for the handshake, in seconds. + reconnect: + Whether to attempt reconnection on failure. + reconnect_on_session_invalid: + Whether to reconnect when Discord invalidates the session (close code + 4006), e.g. after the channel empties and the DAVE session is torn + down. Defaults to ``False`` (disconnect instead of reconnecting). + self_deaf: + Whether to join self-deafened. + self_mute: + Whether to join self-muted. + preserve_backoff: + Whether to keep the current exponential backoff instead of resetting + it. ``_full_reconnect`` passes ``True`` so the delay keeps growing + across retry attempts; a fresh connect resets it. + + Raises + ------ + RuntimeError + If no shard can be resolved for the guild. + TimeoutError + If the handshake does not complete within ``timeout``. + """ + shard = self._get_shard() + if shard is None: + raise RuntimeError(f"Could not resolve a shard for guild {self.guild_id}") + + self._reconnect = reconnect + self._reconnect_on_session_invalid = reconnect_on_session_invalid + self._self_mute = self_mute + self._self_deaf = self_deaf + self._closing = False + + # Only reset the exponential backoff on a genuinely fresh/initial + # connect. The reconnect loop in _full_reconnect() drives its own + # backoff and passes preserve_backoff=True so that each retry's + # _connect() call does NOT reset it -- otherwise the delay would never + # grow and every attempt would sleep the base delay. + if not preserve_backoff: + self.backoff.reset() + + self._state_event.clear() + self._server_event.clear() + self._ready_event.clear() + self._connected_event.clear() + + # Join the voice client's channel, which is the authoritative target. + # Do NOT use self.channel_id here: the reconnect bounce sends op4 with + # channel_id=None and the gateway echoes a VOICE_STATE_UPDATE that + # clears self.channel_id, so reading it could rejoin the wrong channel. + await shard.change_voice_state( + guild_id=self.guild_id, + channel_id=self.voice_client.channel.id, + self_mute=self_mute, + self_deaf=self_deaf, + ) + + await asyncio.wait_for(self._wait_for_handshake(), timeout) + + async def _wait_for_handshake(self) -> None: + """ Wait for the gateway handshake then drive the voice socket to connected. """ + await self._state_event.wait() + await self._server_event.wait() + + self.socket = VoiceSocket(self) + await self.socket.connect() + + await self._connected_event.wait() + + def _on_socket_closed(self, close_code: int | None) -> None: + """ + React to the voice websocket closing by deciding whether to reconnect. + + Parameters + ---------- + close_code: + The websocket close code, if one was reported. + """ + if self._closing: + return + + if self._reconnect_task is not None and not self._reconnect_task.done(): + return + + self._reconnect_task = asyncio.create_task( + self._handle_close(close_code), + name=f"discord.http/voice/connection-{self.guild_id}/reconnect" + ) + + async def _handle_close(self, close_code: int | None) -> None: + """ + Drive reconnect/resume logic based on the voice gateway close code. + + Parameters + ---------- + close_code: + The websocket close code, if one was reported. + """ + if close_code in (VoiceCloseCode.disconnected, VoiceCloseCode.call_terminated): + _log.debug(f"Voice connection for guild {self.guild_id} disconnected (code {close_code}); tearing down") + await self._teardown_and_remove() + return + + if close_code == VoiceCloseCode.rate_limited: + _log.warning(f"Voice connection for guild {self.guild_id} was rate limited (code {close_code}); not reconnecting") + await self._teardown_and_remove() + return + + if close_code == VoiceCloseCode.voice_server_crashed: + _log.debug(f"Voice server for guild {self.guild_id} crashed (code {close_code}); resuming") + await self._resume() + return + + if close_code == VoiceCloseCode.session_invalid: + # Discord invalidates the session (4006) most commonly when another + # member leaves/rejoins and the DAVE/MLS group is rebuilt. The bot is + # still in the channel, so reconnecting only helps if the caller opted + # in; otherwise we disconnect rather than retry-and-time-out. + if not (self._reconnect and self._reconnect_on_session_invalid): + _log.debug(f"Voice session for guild {self.guild_id} invalidated (code {close_code}); disconnecting") + await self._teardown_and_remove() + return + + # Try a soft reconnect first: re-open the voice websocket and + # re-IDENTIFY with the existing token/session, WITHOUT leaving the + # channel. This avoids a visible disconnect/rejoin blip when the + # credentials are still usable. + _log.debug(f"Voice session for guild {self.guild_id} invalidated (code {close_code}); attempting soft reconnect") + if await self._soft_reconnect(): + _log.debug(f"Voice connection for guild {self.guild_id} recovered without rejoining") + return + + # The existing credentials are no longer usable; fall back to a full + # reconnect that drops and re-acquires the gateway voice state. + _log.debug(f"Soft reconnect for guild {self.guild_id} failed; falling back to rejoin") + await self._full_reconnect(close_code, force_refresh=True) + return + + if close_code in (VoiceCloseCode.normal, VoiceCloseCode.going_away): + _log.debug(f"Voice connection for guild {self.guild_id} closed cleanly (code {close_code})") + await self._teardown_and_remove() + return + + if not self._reconnect: + _log.debug(f"Voice connection for guild {self.guild_id} closed (code {close_code}); reconnect disabled") + await self._teardown_and_remove() + return + + await self._full_reconnect(close_code) + + async def _resume(self) -> None: + """ Re-open the voice websocket and RESUME (op 7) the existing session. """ + if self.socket is not None: + self.socket._request_close() + await self.socket.close() + + try: + self._connected_event.clear() + self.socket = VoiceSocket(self) + await self.socket.connect(resume=True) + except Exception as exc: + _log.debug(f"Voice resume for guild {self.guild_id} failed; falling back to full reconnect", exc_info=exc) + await self._full_reconnect(int(VoiceCloseCode.voice_server_crashed)) + + async def _soft_reconnect(self, timeout: float = 10.0) -> bool: + """ + Re-open the voice websocket and re-IDENTIFY without leaving the channel. + + On a 4006 the gateway voice state (session id) and the voice server + token/endpoint are often still valid; the bot never left the channel. + Opening a fresh socket and sending IDENTIFY (op 0) with those existing + credentials can recover the session with no user-visible blip. Returns + ``True`` on a successful handshake, ``False`` if the credentials are + missing or the handshake does not complete in time (the caller then + falls back to a full leave/rejoin reconnect). + + Parameters + ---------- + timeout: + How long to wait for the re-handshake to complete, in seconds. + """ + if not (self.session_id and self.token and self.endpoint): + return False + + # Tear down the old socket/UDP locally WITHOUT sending op4, so the gateway + # keeps our voice state and we can reuse the session id and token. + if self.socket is not None: + self.socket._request_close() + await self.socket.close() + self.socket = None + + if self.transport is not None: + self.transport.close() + self.transport = None + self.udp = None + + self._ready_event.clear() + self._connected_event.clear() + + try: + self.socket = VoiceSocket(self) + await self.socket.connect() + await asyncio.wait_for(self._connected_event.wait(), timeout) + except Exception as exc: + _log.debug(f"Soft reconnect for guild {self.guild_id} did not complete", exc_info=exc) + return False + + return True + + async def _full_reconnect(self, close_code: int | None, *, force_refresh: bool = False) -> None: + """ + Re-issue op4 and run a fresh handshake, retrying with exponential backoff. + + Parameters + ---------- + close_code: + The close code that triggered the reconnect, for logging. + force_refresh: + When ``True``, drop and re-acquire the gateway voice state (leave and + rejoin the channel) before each attempt. Needed for a 4006 where the + bot is still in the channel, so re-issuing op4 with the same channel + would be a no-op and yield no fresh VOICE_SERVER_UPDATE. Defaults to + ``False`` so unrelated reconnects do not cause a visible leave/rejoin. + """ + if self.socket is not None: + self.socket._request_close() + await self.socket.close() + self.socket = None + + if self.transport is not None: + self.transport.close() + self.transport = None + self.udp = None + + # Each attempt below deliberately re-issues op4 (change_voice_state) via + # _connect(..., preserve_backoff=True). The backoff is reset once here + # and then grows across attempts (_connect() skips resetting it). + self.backoff.reset() + + max_attempts = self.client.voice_reconnect_attempts + + for attempt in range(1, max_attempts + 1): + if self._closing: + return + + delay = self.backoff.delay() + _log.debug( + f"Reconnecting voice for guild {self.guild_id} (close code {close_code}), " + f"attempt {attempt}/{max_attempts} in {delay:.2f}s" + ) + await asyncio.sleep(delay) + + try: + if force_refresh: + # Force Discord to allocate a fresh voice server before + # rejoining (only needed for the 4006 leave/rejoin fallback). + await self._force_voice_refresh() + await self._connect( + reconnect=self._reconnect, + reconnect_on_session_invalid=self._reconnect_on_session_invalid, + self_deaf=self._self_deaf, + self_mute=self._self_mute, + # Preserve the growing backoff across reconnect attempts. + preserve_backoff=True, + ) + except Exception as exc: + _log.debug(f"Voice reconnect attempt {attempt} for guild {self.guild_id} failed", exc_info=exc) + continue + else: + _log.debug(f"Voice connection for guild {self.guild_id} reconnected") + return + + _log.error( + f"Voice connection for guild {self.guild_id} could not reconnect " + f"after {max_attempts} attempts; tearing down" + ) + await self._teardown_and_remove() + + async def _force_voice_refresh(self) -> None: + """ Drop the gateway voice state so Discord re-allocates a fresh voice server. """ + shard = self._get_shard() + if shard is None: + return + + self._left_event.clear() + try: + await shard.change_voice_state(guild_id=self.guild_id, channel_id=None) + except Exception as exc: + _log.debug(f"Failed to send voice-state reset for guild {self.guild_id}", exc_info=exc) + return + + try: + await asyncio.wait_for(self._left_event.wait(), timeout=5.0) + except TimeoutError: + # No leave ack arrived (e.g. the bot was already out); a short settle + # still lets the gateway register the reset before we rejoin. + await asyncio.sleep(0.25) + + async def _teardown_and_remove(self) -> None: + """ Tear down the connection and remove the voice client from the registry. """ + try: + await self.voice_client.disconnect(force=True) + except Exception as exc: + _log.debug(f"Error during voice teardown for guild {self.guild_id}", exc_info=exc) + + def on_voice_state_update(self, data: dict) -> None: + """ + Handle a VOICE_STATE_UPDATE for the bot. + + Parameters + ---------- + data: + The raw voice state update payload. + """ + self.session_id = data.get("session_id") or self.session_id + + previous_channel_id = self.channel_id + channel_id = data.get("channel_id") + self.channel_id = int(channel_id) if channel_id is not None else None + + # Same-server moves do not produce a fresh voice READY. Use the gateway's + # state acknowledgement as the authoritative move boundary and clear the + # old channel's SSRC ownership without detaching the active sink. + if previous_channel_id is not None and self.channel_id is not None and previous_channel_id != self.channel_id: + self.voice_client._receiver.reset() + + # Discord issues a new token for channel moves even when the endpoint is + # unchanged, and the old voice session cannot be reused. If the server + # update arrived first, wait until this state update supplies the new + # channel/session before opening the replacement voice socket. Its fresh + # SESSION_DESCRIPTION will then initialise DAVE for the new MLS group. + if self.channel_id == self._move_target_channel_id and self._move_server_update_received: + self._move_target_channel_id = None + self._move_server_update_received = False + self._schedule_server_migration() + + # Track leave/rejoin so the reconnect bounce can await the leave ack. + if self.channel_id is None: + self._left_event.set() + else: + self._left_event.clear() + + if self.session_id is not None: + self._state_event.set() + + def on_voice_server_update(self, data: dict) -> None: + """ + Handle a VOICE_SERVER_UPDATE for the guild. + + Parameters + ---------- + data: + The raw voice server update payload. + """ + previous_token = self.token + previous_endpoint = self.endpoint + was_connected = self.is_connected() + + self.token = data.get("token") + + endpoint = data.get("endpoint") + if endpoint: + # Discord sends the endpoint as "host:port" without a scheme, and the + # port is NOT always 443 (e.g. "c-ams20-....discord.media:2053"). The + # token/session are bound to that specific host:port, so we MUST keep + # the port intact -- connecting to host:443 instead reaches a + # different voice server instance and Discord closes the socket with + # 4006 "Session is no longer valid". + # + # ``urlparse`` (which ``utils.URL`` wraps) treats a bare "host:port" + # as scheme:path, so we normalise by stripping any existing scheme + # and prepending "wss://" before parsing, then reconstruct the + # schemeless host[:port] string that self.endpoint is meant to hold. + host_port = endpoint.rstrip("/") + for scheme in ("wss://", "https://"): + if host_port.startswith(scheme): + host_port = host_port[len(scheme):] + break + + url = URL("wss://" + host_port) + if url.port is not None: + self.endpoint = f"{url.host}:{url.port}" + else: + self.endpoint = url.host or host_port + + server_id = data.get("guild_id") or data.get("server_id") + self.server_id = int(server_id) if server_id is not None else None + + if self.token is not None and self.endpoint is not None: + self._server_event.set() + + credentials_changed = self.token != previous_token or self.endpoint != previous_endpoint + if not (was_connected and credentials_changed): + return + + if self._move_target_channel_id is not None and self.channel_id != self._move_target_channel_id: + self._move_server_update_received = True + return + + self._move_target_channel_id = None + self._move_server_update_received = False + self._schedule_server_migration() + + def _schedule_server_migration(self) -> None: + """ Schedule one reconnect after a mid-session server or channel migration. """ + reconnecting = self._reconnect_task is not None and not self._reconnect_task.done() + if self.token is None or self.endpoint is None or self._closing or reconnecting: + return + + self._reconnect_task = asyncio.create_task( + self._migrate_voice_server(), + name=f"discord.http/voice/connection-{self.guild_id}/migration" + ) + + async def _migrate_voice_server(self) -> None: + """ Reconnect to credentials supplied by a mid-session voice server update. """ + _log.debug(f"Voice server for guild {self.guild_id} changed; reconnecting to the new endpoint") + if await self._soft_reconnect(): + return + + _log.debug(f"Voice server migration for guild {self.guild_id} failed; refreshing voice state") + await self._full_reconnect(None, force_refresh=True) + + async def on_ready(self, data: dict) -> None: + """ + Handle the voice READY (op 2): set up UDP and select the protocol. + + Parameters + ---------- + data: + The READY payload containing ssrc, ip, port and modes. + """ + # A READY means a fresh session, so every SSRC is newly allocated and the + # old mappings are stale. Not done on RESUMED (op 9), which continues the + # same session with the same SSRCs. + self.voice_client._receiver.reset() + + self.ssrc = int(data["ssrc"]) + ip = data["ip"] + port = int(data["port"]) + modes = data.get("modes", []) + + self.mode = next((m for m in SUPPORTED_MODES if m in modes), None) + if self.mode is None: + raise RuntimeError( + "Discord advertised no encryption mode supported by this library; " + f"offered {modes}, supported {list(SUPPORTED_MODES)}" + ) + + self.transport, self.udp = await create_udp(self, ip, port) + + discovered_ip, discovered_port = await self.udp.discover_ip(self.ssrc) + self.endpoint_ip = discovered_ip + self.endpoint_port = discovered_port + + self._ready_event.set() + + if self.socket is not None: + await self.socket.send_select_protocol(discovered_ip, discovered_port, self.mode) + + async def on_session_description(self, data: dict) -> None: + """ + Handle the SESSION_DESCRIPTION (op 4): build the encryptor. + + Parameters + ---------- + data: + The session description payload with the secret key and mode. + """ + secret_key = bytes(data["secret_key"]) + self.secret_key = secret_key + self.mode = data.get("mode", self.mode) + self.encryptor = Encryptor(secret_key) + + dave_version = int(data.get("dave_protocol_version", 0) or 0) + self.dave_protocol_version = dave_version + if dave_version > 0: + await self.reinit_dave_session() + elif self.dave_session is not None: + # DAVE was negotiated off (version 0) but a prior connection left a + # session behind. Tear down the MLS session so can_encrypt() and the + # opus wrappers don't keep using stale E2EE state. DaveManager has no + # dedicated close/cleanup; reinit(0) clears its internal session, then + # we drop the reference. + await self.dave_session.reinit(0) + self.dave_session = None + + self._connected_event.set() + + async def on_speaking(self, data: dict) -> None: + """ + Handle a SPEAKING (op 5) frame from another user. + + Parameters + ---------- + data: + The speaking payload with ssrc, user_id and speaking flags. + """ + ssrc = data.get("ssrc") + user_id = data.get("user_id") + if ssrc is not None and user_id is not None: + # Mapped even when not listening: SPEAKING only fires on the leading + # edge of speech, so a sink attached later still needs this mapping. + self.voice_client._receiver.add_ssrc(int(ssrc), int(user_id)) + + async def on_client_disconnect(self, data: dict) -> None: + """ + Handle a CLIENT_DISCONNECT (op 13) frame: forget the user's receive state. + + Frees the SSRC mappings and per-SSRC Opus decoder held for the user, so + a long-lived connection in a busy channel does not accumulate state for + users who have left. + + Parameters + ---------- + data: + The client disconnect payload with the user_id that left. + """ + user_id = data.get("user_id") + if user_id is not None: + self.voice_client._receiver.remove_user(int(user_id)) + + async def on_resumed(self, data: dict) -> None: # noqa: ARG002 + """ + Handle the RESUMED (op 9) frame. + + Parameters + ---------- + data: + The resumed payload. + """ + _log.debug(f"Voice connection for guild {self.guild_id} resumed") + # A successful RESUMED frame means the connection is live again. _resume() + # cleared _connected_event when re-opening the socket, so re-set it here; + # otherwise is_connected() would stay False forever after a server crash. + self._connected_event.set() + + async def on_dave_binary(self, opcode: int, payload: bytes) -> None: + """ + Handle an inbound binary DAVE frame. + + Parameters + ---------- + opcode: + The voice opcode of the binary frame. + payload: + The binary payload following the opcode. + """ + if not has_dave: + _log.warning(f"Received DAVE binary op {opcode} but the davey library is not available") + return + + if self.dave_session is None: + await self.reinit_dave_session() + + if self.dave_session is not None: + await self.dave_session.handle_binary(opcode, payload) + + async def on_dave_json(self, opcode: int, data: dict) -> None: + """ + Handle an inbound JSON DAVE control op (transition/epoch, ops 21/22/24). + + These ops arrive as JSON text frames rather than binary DAVE frames, so + they carry the decoded ``d`` payload instead of raw bytes. + + Parameters + ---------- + opcode: + The voice opcode of the control frame. + data: + The decoded JSON ``d`` payload. + """ + if not has_dave: + _log.warning(f"Received DAVE JSON op {opcode} but the davey library is not available") + return + + if self.dave_session is None: + await self.reinit_dave_session() + + if self.dave_session is not None: + await self.dave_session.handle_json(opcode, data) + + async def reinit_dave_session(self) -> None: + """ Create or reset the DAVE session for the negotiated protocol version. """ + if not has_dave: + if self.dave_protocol_version > 0: + raise RuntimeError( + "Discord negotiated a DAVE protocol version but the davey library is not installed" + ) + return + + if self.dave_session is None: + self.dave_session = DaveManager(self) + + await self.dave_session.reinit(self.dave_protocol_version) + + def can_encrypt(self) -> bool: + """ Whether a DAVE session is ready to encrypt Opus payloads. """ + # Delegate rather than re-deriving: DaveManager also requires a non-zero + # protocol version, so after an EXECUTE_TRANSITION down to version 0 the + # session is still alive and ready but nothing is being encrypted. The + # receiver drops unmapped-SSRC packets based on this, so disagreeing here + # would drop plain Opus. + return self.dave_session is not None and self.dave_session.can_encrypt() + + def dave_encrypt_opus(self, opus: bytes) -> bytes: + """ + Encrypt an Opus payload through the DAVE session, if active. + + Parameters + ---------- + opus: + The Opus payload to encrypt. + + Returns + ------- + The DAVE-encrypted Opus payload, or the input unchanged when inactive. + """ + if self.dave_session is None: + return opus + return self.dave_session.encrypt_opus(opus) + + def dave_decrypt_opus(self, user_id: int, opus: bytes) -> bytes: + """ + Decrypt an Opus payload through the DAVE session, if active. + + Parameters + ---------- + user_id: + The user ID the payload was received from. + opus: + The Opus payload to decrypt. + + Returns + ------- + The decrypted Opus payload, or the input unchanged when inactive. + """ + if self.dave_session is None: + return opus + return self.dave_session.decrypt_opus(user_id, opus) + + def _cancel_reconnect_task(self) -> None: + """ Cancel any pending reconnect task, unless it is the current task. """ + if self._reconnect_task is not None and self._reconnect_task is not asyncio.current_task(): + self._reconnect_task.cancel() + self._reconnect_task = None + + def _clear_transport_state(self) -> None: + """ Drop the transport/UDP/encryption state and clear the ready/connected events. """ + if self.transport is not None: + self.transport.close() + self.transport = None + + self.udp = None + self.encryptor = None + self.secret_key = None + self.ssrc = None + self._connected_event.clear() + self._ready_event.clear() + + async def disconnect(self, *, force: bool = True) -> None: + """ + Tear down the voice connection. + + Parameters + ---------- + force: + Whether to force the disconnect even if the gateway update fails. + """ + self._closing = True + self._cancel_reconnect_task() + + # Request the close before sending op4 so the close handler does not + # treat the impending socket close as an unexpected disconnect. + if self.socket is not None: + self.socket._request_close() + + try: + shard = self._get_shard() + if shard is not None: + await shard.change_voice_state(guild_id=self.guild_id, channel_id=None) + except Exception as exc: + if not force: + raise + _log.debug(f"Failed to send voice disconnect for guild {self.guild_id}", exc_info=exc) + + if self.socket is not None: + await self.socket.close() + self.socket = None + + self._clear_transport_state() + + async def close_transport(self) -> None: + """ Close the websocket and UDP transport without notifying the gateway. """ + # Used when the owning shard has been reset or killed, so op4 can no + # longer be sent. Cancels any pending reconnect and clears local state. + self._closing = True + self._cancel_reconnect_task() + + if self.socket is not None: + self.socket._request_close() + await self.socket.close() + self.socket = None + + self._clear_transport_state() + + async def move_to(self, channel: "PartialChannel") -> None: + """ + Move the connection to a different voice channel. + + Parameters + ---------- + channel: + The channel to move to. + """ + shard = self._get_shard() + if shard is None: + raise RuntimeError(f"Could not resolve a shard for guild {self.guild_id}") + + self._move_target_channel_id = channel.id + self._move_server_update_received = False + try: + await shard.change_voice_state(guild_id=self.guild_id, channel_id=channel.id) + except Exception: + self._move_target_channel_id = None + raise diff --git a/discord_http/voice/dave.py b/discord_http/voice/dave.py new file mode 100644 index 0000000..cf41235 --- /dev/null +++ b/discord_http/voice/dave.py @@ -0,0 +1,421 @@ +import logging + +from typing import TYPE_CHECKING + +try: + import davey + has_dave = True +except ImportError: + davey = None + has_dave = False + +if TYPE_CHECKING: + # ``davey`` is an optional runtime dependency, but it is always present in + # the type-check environments (it is part of ``--all-extras``), so its own + # type stubs are the authority on the session API. + from davey import DaveSession + + from .connection import VoiceConnection + +from .enums import VoiceOpType + +__all__ = ( + "DaveManager", + "has_dave", + "max_protocol_version", +) + +_log = logging.getLogger(__name__) + + +def max_protocol_version() -> int: + """ The maximum DAVE protocol version this installation can negotiate (``0`` if davey is absent). """ + if has_dave and davey is not None: + return int(davey.DAVE_PROTOCOL_VERSION) + return 0 + + +class DaveManager: + """ Manages DAVE (E2EE) for a voice connection, degrading to a no-op when davey is absent. """ + + __slots__ = ( + "_connection", + "_pending_transitions", + "_session", + "_version", + ) + + def __init__(self, connection: "VoiceConnection"): + self._connection: "VoiceConnection" = connection + self._session: "DaveSession | None" = None + self._version: int = 0 + # transition_id -> protocol version. A dict rather than a single slot so + # overlapping transitions (e.g. during member churn) cannot clobber each + # other before their EXECUTE_TRANSITION arrives. + self._pending_transitions: dict[int, int] = {} + + @property + def ready(self) -> bool: + """ Whether the underlying MLS session has completed its handshake. """ + return self._session is not None and bool(self._session.ready) + + @property + def voice_privacy_code(self) -> str | None: + """ The privacy code users can compare out-of-band to verify the E2EE session, if any. """ + if self._session is None: + return None + code = self._session.voice_privacy_code + return str(code) if code is not None else None + + def can_encrypt(self) -> bool: + """ Whether end-to-end encryption is currently active and usable. """ + return self._version > 0 and self._session is not None and self.ready + + async def reinit(self, version: int) -> None: + """ + Create or reinitialise the MLS session for a given protocol version. + + When ``version`` is greater than ``0`` but ``davey`` is not installed, a clear, + actionable :class:`RuntimeError` is raised telling the user how to install the + optional dependency. When a session is created, its serialized MLS key package is + sent to the voice gateway. + + Parameters + ---------- + version: + The negotiated DAVE protocol version. ``0`` disables end-to-end encryption. + + Raises + ------ + RuntimeError + If a non-zero DAVE version is requested but ``davey`` is not installed. + """ + self._version = version + # Any pending transitions belong to the previous session; drop them. + self._pending_transitions.clear() + + if version <= 0: + self._session = None + return + + # Checked before davey: with no channel there is nothing to encrypt for, + # whether or not the optional package is installed. + channel_id = self._connection.channel_id + if channel_id is None: + self._session = None + self._version = 0 + return + + if not has_dave or davey is None: + raise RuntimeError( + "Discord negotiated DAVE end-to-end encryption " + f"(protocol version {version}), but the optional 'davey' package is not " + 'installed. Install it with: pip install "discord.http[voice]"' + ) + + try: + self._session = davey.DaveSession( + version, + self._connection.user_id, + channel_id, + ) + except Exception as exc: + # Reset the version too: leaving it non-zero would make can_encrypt() + # depend solely on a session that does not exist, and the encrypt + # helpers would silently pass plaintext into a channel Discord + # believes is end-to-end encrypted. + _log.warning(f"Failed to initialise DAVE session: {exc}") + self._session = None + self._version = 0 + return + + await self._send_key_package() + + async def _send_key_package(self) -> None: + """ Serialize and send our MLS key package to the voice gateway. """ + if self._session is None: + return + + key_package = self._session.get_serialized_key_package() + await self._connection.socket.send_binary( + int(VoiceOpType.dave_mls_key_package), key_package + ) + + def set_passthrough_mode(self, enabled: bool) -> None: + """ + Toggle passthrough mode on the MLS session. + + While in passthrough mode the session does not transform media, allowing audio to + flow during transitions where some participants are not yet on the new epoch. + + Parameters + ---------- + enabled: + ``True`` to pass media through unchanged, ``False`` to resume encryption. + """ + if self._session is None: + return + + self._session.set_passthrough_mode(enabled) + + def encrypt_opus(self, opus: bytes) -> bytes: + """ + End-to-end encrypt an outbound Opus frame. + + Parameters + ---------- + opus: + The plaintext Opus frame. + + Returns + ------- + The encrypted frame, or the input unchanged when E2EE is not active. + """ + if not self.can_encrypt() or self._session is None: + return opus + return bytes(self._session.encrypt_opus(opus)) + + def decrypt_opus(self, user_id: int, opus: bytes) -> bytes: + """ + End-to-end decrypt an inbound Opus frame from a given user. + + Parameters + ---------- + user_id: + The user the frame originated from. + opus: + The received Opus frame. + + Returns + ------- + The decrypted frame, or the input unchanged when E2EE is not active. + """ + if not self.can_encrypt() or self._session is None or davey is None: + return opus + return bytes(self._session.decrypt(user_id, davey.MediaType.audio, opus)) + + async def handle_binary(self, opcode: int, payload: bytes) -> None: + """ + Dispatch a binary DAVE/MLS operation (opcodes 21-31) received from the gateway. + + Parameters + ---------- + opcode: + The voice opcode, expected to be one of the DAVE ops 21-31. + payload: + The raw binary payload following the opcode. + """ + match opcode: + case VoiceOpType.dave_mls_external_sender: + self._handle_external_sender(payload) + case VoiceOpType.dave_mls_proposals: + await self._handle_proposals(payload) + case VoiceOpType.dave_mls_announce_commit_transition: + await self._handle_commit(payload) + case VoiceOpType.dave_mls_welcome: + await self._handle_welcome(payload) + case _: + _log.debug(f"Unhandled DAVE binary opcode {opcode}") + + async def handle_json(self, opcode: int, data: dict) -> None: + """ + Dispatch a JSON DAVE control op (21, 22, 24) received from the gateway. + + Unlike the MLS data ops (25-31), the transition/epoch control ops arrive as + regular JSON text frames rather than binary frames, so they are routed here + with the decoded ``d`` payload instead of raw bytes. + + Parameters + ---------- + opcode: + The voice opcode, expected to be one of 21, 22 or 24. + data: + The decoded JSON ``d`` payload of the frame. + """ + match opcode: + case VoiceOpType.dave_prepare_transition: + await self._handle_prepare_transition(data) + case VoiceOpType.dave_execute_transition: + await self._handle_execute_transition(data) + case VoiceOpType.dave_prepare_epoch: + await self._handle_prepare_epoch(data) + case _: + _log.debug(f"Unhandled DAVE JSON opcode {opcode}") + + async def _handle_prepare_transition(self, data: dict) -> None: + """ Handle PREPARE_TRANSITION (21): record the pending transition and acknowledge. """ + transition_id = int(data.get("transition_id", 0) or 0) + version = int(data.get("protocol_version", 0) or 0) + self._pending_transitions[transition_id] = version + + if transition_id == 0: + await self._execute_transition(transition_id, version) + else: + await self._connection.socket.send_transition_ready(transition_id) + + async def _handle_execute_transition(self, data: dict) -> None: + """ Handle EXECUTE_TRANSITION (22): apply the pending version and passthrough state. """ + transition_id = int(data.get("transition_id", 0) or 0) + + version = self._pending_transitions.get(transition_id) + if version is not None: + await self._execute_transition(transition_id, version) + return + + _log.debug(f"Received EXECUTE_TRANSITION for unknown transition {transition_id}") + + async def _execute_transition(self, transition_id: int, version: int) -> None: + """ Apply a transition: switch protocol version and update passthrough mode. """ + self._version = version + self.set_passthrough_mode(version == 0) + self._pending_transitions.pop(transition_id, None) + _log.debug(f"Executed DAVE transition {transition_id} to version {version}") + + async def _handle_prepare_epoch(self, data: dict) -> None: + """ Handle PREPARE_EPOCH (24), creating a session only for a new MLS group. """ + # Epoch 1 is the server's explicit signal that this is a new MLS group. + # Later epochs are normal membership transitions within the same group and + # must not discard the current session or send a replacement key package. + if int(data.get("epoch", 0) or 0) != 1: + return + + version = int(data.get("protocol_version", 0) or 0) + self._connection.dave_protocol_version = version + await self.reinit(version) + + def _handle_external_sender(self, payload: bytes) -> None: + """ Handle MLS_EXTERNAL_SENDER (25): register the gateway's external sender. """ + if self._session is None: + return + + self._session.set_external_sender(payload) + + async def _handle_proposals(self, payload: bytes) -> None: + """ + Handle MLS_PROPOSALS (27): process proposals and forward any commit/welcome. + + The payload is ``operation_type(1B) + proposals``: the first byte selects + append (``0``) vs revoke, and the remainder is the serialized proposals. + """ + if self._session is None or davey is None: + return + + if len(payload) < 1: + return + + # Payload is ``operation_type(1B) + proposals``: first byte selects append (0) vs revoke. + optype = payload[0] + proposals = payload[1:] + operation_type = ( + davey.ProposalsOperationType.append + if optype == 0 + else davey.ProposalsOperationType.revoke + ) + + try: + result = self._session.process_proposals(operation_type, proposals) + except Exception as exc: + _log.debug(f"Failed to process MLS proposals: {exc}") + await self._recover_from_invalid_commit() + return + + commit_welcome = self._extract_commit_welcome(result) + if commit_welcome is not None: + await self._connection.socket.send_binary( + int(VoiceOpType.dave_mls_commit_welcome), commit_welcome + ) + + async def _handle_commit(self, payload: bytes) -> None: + """ + Handle MLS_ANNOUNCE_COMMIT_TRANSITION (29): apply the announced commit. + + The payload is ``transition_id(2B big-endian) + commit``. + """ + if self._session is None: + return + + # Payload is ``transition_id(2B big-endian) + commit``. + transition_id = int.from_bytes(payload[:2], "big") if len(payload) >= 2 else 0 + commit = payload[2:] + + try: + self._session.process_commit(commit) + except Exception as exc: + _log.debug(f"Failed to process MLS commit: {exc}") + await self._recover_from_invalid_commit() + return + + if transition_id != 0: + self._pending_transitions[transition_id] = self._pending_transition_version(transition_id) + await self._connection.socket.send_transition_ready(transition_id) + + async def _handle_welcome(self, payload: bytes) -> None: + """ + Handle MLS_WELCOME (30): join the group from the received welcome message. + + The payload is ``transition_id(2B big-endian) + welcome``. + """ + if self._session is None: + return + + # Payload is ``transition_id(2B big-endian) + welcome``. + transition_id = int.from_bytes(payload[:2], "big") if len(payload) >= 2 else 0 + welcome = payload[2:] + + try: + self._session.process_welcome(welcome) + except Exception as exc: + _log.debug(f"Failed to process MLS welcome: {exc}") + await self._recover_from_invalid_commit() + return + + if transition_id != 0: + self._pending_transitions[transition_id] = self._pending_transition_version(transition_id) + await self._connection.socket.send_transition_ready(transition_id) + + def _pending_transition_version(self, transition_id: int) -> int: + """ + Resolve the protocol version to record for a transition. + + The transition is driven by an incoming commit/welcome. When + ``DAVE_PREPARE_TRANSITION`` already recorded the negotiated target + version for this ``transition_id``, preserve it so the later + ``EXECUTE_TRANSITION`` applies the negotiated epoch rather than the + current version. Otherwise (no matching pending transition, e.g. the + commit/welcome arrived first) fall back to the current version. + """ + return self._pending_transitions.get(transition_id, self._version) + + async def _recover_from_invalid_commit(self) -> None: + """ Notify the gateway of an invalid commit/welcome and reinitialise the session. """ + await self._connection.socket.send_binary( + int(VoiceOpType.dave_mls_invalid_commit_welcome), b"" + ) + await self.reinit(self._version) + + @staticmethod + def _extract_commit_welcome(result: object) -> bytes | None: + """ + Extract the bytes to send for a ``davey.CommitWelcome`` result. + + ``davey``'s ``process_proposals`` returns ``None`` or a ``CommitWelcome`` + carrying a ``commit`` and an optional ``welcome``. Discord expects them + concatenated as ``commit + welcome`` (commit alone when there is no welcome). + + Parameters + ---------- + result: + The value returned by ``davey``'s proposal processing. + + Returns + ------- + The serialized commit/welcome bytes, or ``None`` when there is nothing to send. + """ + commit = getattr(result, "commit", None) + if commit is None: + return None + + welcome = getattr(result, "welcome", None) + if welcome: + return bytes(commit) + bytes(welcome) + return bytes(commit) diff --git a/discord_http/voice/encryptor.py b/discord_http/voice/encryptor.py new file mode 100644 index 0000000..f9fa44d --- /dev/null +++ b/discord_http/voice/encryptor.py @@ -0,0 +1,86 @@ +import struct + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +__all__ = ( + "Encryptor", +) + + +class Encryptor: + """ + Handles Discord voice transport encryption. + + Implements the ``aead_aes256_gcm_rtpsize`` mode, which encrypts the RTP + payload with AES-256-GCM while authenticating the unencrypted RTP header + as additional authenticated data (AAD). + """ + + __slots__ = ( + "_aead", + "_nonce", + ) + + def __init__(self, secret_key: bytes): + self._aead = AESGCM(bytes(secret_key)) + self._nonce = 0 + + def encrypt(self, header: bytes, plaintext: bytes) -> bytes: + """ + Encrypt an RTP payload. + + Parameters + ---------- + header: + The unencrypted RTP header, used as the additional authenticated data. + plaintext: + The payload to encrypt, usually an Opus frame. + + Returns + ------- + The packet, consisting of the header, the ciphertext, and the 4-byte big-endian nonce counter. + """ + # Reusing a (key, nonce) pair with AES-GCM breaks the encryption entirely, + # so refuse to wrap the 4-byte counter back to 0. At ~50 packets/sec this + # takes ~2.7 years of one connection staying open, so a loud failure that + # forces a reconnect (which rotates the secret key) is sufficient. + if self._nonce >= 2 ** 32: + raise RuntimeError( + "voice nonce counter exhausted; reconnect to rotate the secret key" + ) + + nonce = self._nonce + nonce_bytes = struct.pack(">I", nonce) + b"\x00" * 8 + ciphertext = self._aead.encrypt(nonce_bytes, plaintext, header) + + self._nonce += 1 + + return header + ciphertext + struct.pack(">I", nonce) + + def decrypt(self, packet: bytes) -> bytes: + """ + Decrypt a received RTP packet. + + Parameters + ---------- + packet: + The full received packet, including the header, ciphertext, and trailing nonce counter. + + Returns + ------- + The decrypted payload, usually an Opus frame. + """ + nonce_bytes = packet[-4:] + b"\x00" * 8 + + offset = 12 + csrc_count = packet[0] & 0x0F + offset += csrc_count * 4 + + if packet[0] & 0x10: + length = struct.unpack(">H", packet[offset + 2:offset + 4])[0] + offset += 4 + length * 4 + + header = packet[:offset] + ciphertext = packet[offset:-4] + + return self._aead.decrypt(nonce_bytes, ciphertext, header) diff --git a/discord_http/voice/enums.py b/discord_http/voice/enums.py new file mode 100644 index 0000000..4cf159f --- /dev/null +++ b/discord_http/voice/enums.py @@ -0,0 +1,34 @@ +from ..enums import BaseEnum + +__all__ = ( + "SUPPORTED_MODES", + "VoiceOpType", +) + +SUPPORTED_MODES: tuple[str, ...] = ("aead_aes256_gcm_rtpsize",) + + +class VoiceOpType(BaseEnum): + """ Represents the opcode type of a voice gateway payload. """ + identify = 0 + select_protocol = 1 + ready = 2 + heartbeat = 3 + session_description = 4 + speaking = 5 + heartbeat_ack = 6 + resume = 7 + hello = 8 + resumed = 9 + client_disconnect = 13 + dave_prepare_transition = 21 + dave_execute_transition = 22 + dave_transition_ready = 23 + dave_prepare_epoch = 24 + dave_mls_external_sender = 25 + dave_mls_key_package = 26 + dave_mls_proposals = 27 + dave_mls_commit_welcome = 28 + dave_mls_announce_commit_transition = 29 + dave_mls_welcome = 30 + dave_mls_invalid_commit_welcome = 31 diff --git a/discord_http/voice/gateway_udp.py b/discord_http/voice/gateway_udp.py new file mode 100644 index 0000000..eb54200 --- /dev/null +++ b/discord_http/voice/gateway_udp.py @@ -0,0 +1,168 @@ +import asyncio +import logging +import struct + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .connection import VoiceConnection + +__all__ = ("VoiceUDPProtocol",) + +_log = logging.getLogger(__name__) + + +class VoiceUDPProtocol(asyncio.DatagramProtocol): + """ The UDP transport for Discord voice: IP discovery, RTP routing, and RTCP drop. """ + + def __init__(self, connection: "VoiceConnection"): + self.connection: "VoiceConnection" = connection + """ The voice connection that owns this protocol. """ + + self.transport: asyncio.DatagramTransport | None = None + """ The UDP datagram transport, if connected. """ + + self._discovery_future: asyncio.Future[bytes] | None = None + + def connection_made(self, transport: asyncio.BaseTransport) -> None: + """ + Store the transport once the endpoint is created. + + Parameters + ---------- + transport: + The datagram transport for this protocol. + """ + # A DatagramProtocol is only ever driven by a DatagramTransport; the + # isinstance check narrows the BaseTransport type without a cast. + if isinstance(transport, asyncio.DatagramTransport): + self.transport = transport + + def error_received(self, exc: Exception) -> None: + """ + Log a transport-level error. + + Parameters + ---------- + exc: + The exception reported by the transport. + """ + _log.warning(f"Voice UDP error for guild {self.connection.guild_id}: {exc}") + + def connection_lost(self, exc: Exception | None) -> None: + """ + Handle the transport being closed. + + Parameters + ---------- + exc: + The exception that caused the loss, if any. + """ + if exc is not None: + _log.debug(f"Voice UDP connection lost for guild {self.connection.guild_id}", exc_info=exc) + self.transport = None + + def datagram_received(self, data: bytes, addr: tuple) -> None: # noqa: ARG002 + """ + Route an inbound datagram to the right consumer. + + Parameters + ---------- + data: + The raw datagram payload. + addr: + The source address of the datagram. + """ + if len(data) < 2: + return + + # IP discovery response: 2-byte big-endian type 0x0002 (bytes 0-1) and a + # fixed 74-byte length. Checking all three avoids matching a coincidental + # RTP packet whose second byte happens to be 0x02. + if ( + data[0] == 0x00 + and data[1] == 0x02 + and len(data) >= 74 + and self._discovery_future is not None + and not self._discovery_future.done() + ): + self._discovery_future.set_result(data) + return + + # Drop RTCP control packets. For RTCP the second byte is the raw packet + # type (200-204); for RTP it is the marker bit plus a 7-bit payload type, + # so RTCP must be detected on the unmasked byte before any RTP masking. + if 200 <= data[1] <= 204: + return + + # Otherwise treat it as RTP and hand it to the receiver, which ignores + # the packet while no sink is attached. + self.connection.voice_client._receiver.unpack(data) + + async def discover_ip(self, ssrc: int) -> tuple[str, int]: + """ + Perform IP discovery to learn this client's external address. + + Parameters + ---------- + ssrc: + The SSRC assigned by the voice gateway. + + Returns + ------- + The externally visible IP address and UDP port. + + Raises + ------ + RuntimeError + If the transport is not available. + """ + if self.transport is None: + raise RuntimeError("UDP transport is not available for IP discovery") + + loop = asyncio.get_running_loop() + self._discovery_future = loop.create_future() + + request = struct.pack(">HHI", 0x1, 70, ssrc) + b"\x00" * 66 + self.transport.sendto(request) + + try: + data = await asyncio.wait_for(self._discovery_future, timeout=20.0) + finally: + self._discovery_future = None + + # External IP is a null-terminated string starting at offset 8. + ip_end = data.index(0, 8) + ip = data[8:ip_end].decode("ascii") + port = struct.unpack_from(">H", data, len(data) - 2)[0] + + return ip, port + + +async def create_udp( + connection: "VoiceConnection", + ip: str, + port: int +) -> tuple[asyncio.DatagramTransport, VoiceUDPProtocol]: + """ + Create a connected UDP datagram endpoint for voice. + + Parameters + ---------- + connection: + The voice connection that owns the new protocol. + ip: + The voice server IP address to connect to. + port: + The voice server UDP port to connect to. + + Returns + ------- + The datagram transport and its protocol. + """ + loop = asyncio.get_running_loop() + transport, protocol = await loop.create_datagram_endpoint( + lambda: VoiceUDPProtocol(connection), + remote_addr=(ip, port), + ) + return transport, protocol diff --git a/discord_http/voice/oggparse.py b/discord_http/voice/oggparse.py new file mode 100644 index 0000000..95f64bd --- /dev/null +++ b/discord_http/voice/oggparse.py @@ -0,0 +1,118 @@ +import struct + +from collections.abc import Iterator +from typing import IO + +__all__ = ( + "OggPage", +) + +# 4-byte capture pattern that begins every Ogg page. +_OGG_MAGIC = b"OggS" + +# Fixed header layout that follows the 4-byte capture pattern, little-endian: +# x - version (1 byte, ignored, must be 0) +# B - header_type (1 byte) +# Q - granule_position (8 bytes, signed treated as unsigned here) +# I - bitstream_serial_number (4 bytes) +# I - page_sequence_number (4 bytes) +# I - CRC checksum (4 bytes) +# B - page_segments (1 byte, number of segments N) +# This is exactly 23 bytes, mirroring discord.py's well-known approach. +_HEADER_STRUCT = struct.Struct(" None: + header = stream.read(_HEADER_STRUCT.size) + if len(header) < _HEADER_STRUCT.size: + raise ValueError("Incomplete Ogg page header") + + ( + self.header_type, + self.granule_position, + self.bitstream_serial_number, + self.page_sequence_number, + self.crc_checksum, + page_segments, + ) = _HEADER_STRUCT.unpack(header) + + self.segtable = stream.read(page_segments) + if len(self.segtable) < page_segments: + raise ValueError("Incomplete Ogg page segment table") + + body_length = sum(self.segtable) + self.data = stream.read(body_length) + if len(self.data) < body_length: + raise ValueError("Incomplete Ogg page body") + + def iter_packets(self) -> Iterator[tuple[bytes, bool]]: + """ + Yield the packet chunks contained in this single page. + + Each yielded tuple is ``(packet_bytes, complete)`` where ``complete`` is + ``True`` when the accumulated chunk terminates a packet within this page + (the lacing value was ``0-254``) and ``False`` when the packet continues + into the next page (the final lacing value was exactly ``255``). + + Yields + ------ + tuple[bytes, bool] + A chunk of packet data and whether it completes the packet. + """ + offset = 0 + partial = bytearray() + + for lacing in self.segtable: + chunk = self.data[offset:offset + lacing] + offset += lacing + partial += chunk + + if lacing < 255: + yield bytes(partial), True + partial = bytearray() + + # A trailing run of 255s means the packet spills into the next page. + if partial: + yield bytes(partial), False diff --git a/discord_http/voice/opus.py b/discord_http/voice/opus.py new file mode 100644 index 0000000..f513a2e --- /dev/null +++ b/discord_http/voice/opus.py @@ -0,0 +1,465 @@ +import ctypes +import ctypes.util +import logging + +from ..errors import OpusError, OpusNotLoaded + +__all__ = ( + "OPUS_APPLICATION_AUDIO", + "OPUS_APPLICATION_LOWDELAY", + "OPUS_APPLICATION_VOIP", + "OPUS_SILENCE", + "SAMPLES_PER_FRAME", + "SAMPLE_RATE", + "Decoder", + "Encoder", + "OpusError", + "OpusNotLoaded", + "is_loaded", + "load_opus", +) + +_log = logging.getLogger(__name__) + + +# Audio constants, fixed for Discord voice (48kHz stereo, 20ms frames). +SAMPLE_RATE = 48000 +""" The sample rate Discord expects, in Hz. """ + +CHANNELS = 2 +""" The number of audio channels Discord expects (stereo). """ + +FRAME_LENGTH = 20 +""" The length of a single audio frame, in milliseconds. """ + +SAMPLES_PER_FRAME = SAMPLE_RATE // 1000 * FRAME_LENGTH +""" The number of samples per channel in a single 20ms frame (960). """ + +SAMPLE_SIZE = 2 +""" The size of a single sample, in bytes (signed 16-bit). """ + +FRAME_SIZE = SAMPLES_PER_FRAME * CHANNELS * SAMPLE_SIZE +""" The size of a decoded 20ms PCM frame, in bytes (s16le, stereo). """ + +OPUS_SILENCE = b"\xf8\xff\xfe" +""" The magic Opus frame that encodes silence. """ + + +# Opus application types. +OPUS_APPLICATION_VOIP = 2048 +OPUS_APPLICATION_AUDIO = 2049 +OPUS_APPLICATION_LOWDELAY = 2051 + +# Opus error codes (used when raising OpusError). +OPUS_OK = 0 + + +# Opaque handle types. libopus only ever hands these back as pointers. +EncoderStruct = ctypes.c_void_p +DecoderStruct = ctypes.c_void_p + + +class _OpusLoader: + """ Holds the lazily-loaded libopus handle and its load state on an instance to avoid module-level ``global``. """ + + __slots__ = ("attempted", "lib") + + def __init__(self) -> None: + self.lib: ctypes.CDLL | None = None + """ The loaded libopus shared library, or ``None`` if unavailable. """ + + self.attempted: bool = False + """ Whether a load has been attempted (so it is not retried endlessly). """ + + +_loader = _OpusLoader() + + +def _configure_lib(lib: ctypes.CDLL) -> None: + """ + Configure the ``argtypes`` and ``restype`` of every function we bind. + + Parameters + ---------- + lib: + The freshly loaded libopus shared library. + """ + lib.opus_strerror.argtypes = [ctypes.c_int] + lib.opus_strerror.restype = ctypes.c_char_p + + lib.opus_encoder_get_size.argtypes = [ctypes.c_int] + lib.opus_encoder_get_size.restype = ctypes.c_int + + lib.opus_encoder_create.argtypes = [ + ctypes.c_int, + ctypes.c_int, + ctypes.c_int, + ctypes.POINTER(ctypes.c_int), + ] + lib.opus_encoder_create.restype = EncoderStruct + + lib.opus_encode.argtypes = [ + EncoderStruct, + ctypes.POINTER(ctypes.c_int16), + ctypes.c_int, + ctypes.POINTER(ctypes.c_ubyte), + ctypes.c_int32, + ] + lib.opus_encode.restype = ctypes.c_int32 + + lib.opus_encoder_destroy.argtypes = [EncoderStruct] + lib.opus_encoder_destroy.restype = None + + lib.opus_decoder_get_size.argtypes = [ctypes.c_int] + lib.opus_decoder_get_size.restype = ctypes.c_int + + lib.opus_decoder_create.argtypes = [ + ctypes.c_int, + ctypes.c_int, + ctypes.POINTER(ctypes.c_int), + ] + lib.opus_decoder_create.restype = DecoderStruct + + lib.opus_decode.argtypes = [ + DecoderStruct, + ctypes.POINTER(ctypes.c_ubyte), + ctypes.c_int32, + ctypes.POINTER(ctypes.c_int16), + ctypes.c_int, + ctypes.c_int, + ] + lib.opus_decode.restype = ctypes.c_int + + lib.opus_decoder_destroy.argtypes = [DecoderStruct] + lib.opus_decoder_destroy.restype = None + + lib.opus_packet_get_nb_frames.argtypes = [ctypes.POINTER(ctypes.c_ubyte), ctypes.c_int] + lib.opus_packet_get_nb_frames.restype = ctypes.c_int + + lib.opus_packet_get_samples_per_frame.argtypes = [ctypes.POINTER(ctypes.c_ubyte), ctypes.c_int] + lib.opus_packet_get_samples_per_frame.restype = ctypes.c_int + + +def load_opus(name: str | None = None) -> None: + """ + Load libopus and configure all of its bindings. + + Parameters + ---------- + name: + An explicit path or library name to load. When omitted, the system + library is located with :func:`ctypes.util.find_library`. + + Raises + ------ + OpusError + If an explicit ``name`` was given but the library could not be loaded. + """ + _loader.attempted = True + + location = name + if location is None: + location = ctypes.util.find_library("opus") + + if location is None: + # No library on the system; this is not fatal. The passthrough/E2EE + # paths can still operate without libopus present. + _loader.lib = None + _log.debug("libopus could not be located; Opus encode/decode is unavailable") + return + + try: + lib = ctypes.CDLL(location) + _configure_lib(lib) + except (OSError, AttributeError) as exc: + _loader.lib = None + if name is not None: + # The caller explicitly asked for this library, so surface failure. + raise OpusError(f"Could not load libopus from {location!r}") from exc + _log.warning(f"Found libopus at {location!r} but failed to load it: {exc}") + return + + _loader.lib = lib + _log.debug(f"Successfully loaded libopus from {location!r}") + + +def is_loaded() -> bool: + """ Whether libopus is currently loaded, attempting a lazy load once if not yet tried. """ + if not _loader.attempted: + load_opus() + + return _loader.lib is not None + + +def _get_lib() -> ctypes.CDLL: + """ Return the loaded libopus library, loading it lazily if needed. """ + if not _loader.attempted: + load_opus() + + if _loader.lib is None: + raise OpusNotLoaded("libopus is not loaded; install the Opus shared library to use voice encode/decode") + + return _loader.lib + + +def _strerror(code: int) -> str: + """ + Resolve a libopus error code into a human-readable string. + + Parameters + ---------- + code: + The negative error code returned by a libopus call. + + Returns + ------- + The decoded error string. + """ + lib = _loader.lib + if lib is None: + return f"error code {code}" + + message: bytes | None = lib.opus_strerror(code) + if message is None: + return f"error code {code}" + + return message.decode("utf-8", "replace") + + +def _as_ubyte_ptr(data: bytes) -> "ctypes._Pointer[ctypes.c_ubyte]": + """ + Copy ``data`` into a ctypes buffer and return a ``c_ubyte`` pointer to it. + + The caller must keep a reference to ``data`` alive only for the duration of + the libopus call; libopus does not retain the pointer. + + Parameters + ---------- + data: + The bytes to expose to libopus. + + Returns + ------- + A pointer to the start of a mutable copy of ``data``. + """ + buffer = ctypes.create_string_buffer(bytes(data), len(data)) + return ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte)) + + +def _as_int16_ptr(data: bytes) -> "ctypes._Pointer[ctypes.c_int16]": + """ + Copy ``data`` into a ctypes buffer and return a ``c_int16`` pointer to it. + + Parameters + ---------- + data: + The raw PCM bytes to expose to libopus. + + Returns + ------- + A pointer to the start of a mutable copy of ``data``. + """ + buffer = ctypes.create_string_buffer(bytes(data), len(data)) + return ctypes.cast(buffer, ctypes.POINTER(ctypes.c_int16)) + + +def _check(code: int) -> int: + """ + Raise :class:`OpusError` if ``code`` indicates a libopus failure. + + Parameters + ---------- + code: + The integer return value of a libopus call. + + Returns + ------- + The original ``code`` when it is non-negative. + + Raises + ------ + OpusError + If ``code`` is negative. + """ + if code < OPUS_OK: + raise OpusError(_strerror(code)) + + return code + + +class Encoder: + """ A libopus encoder configured for Discord voice (48kHz, stereo). """ + + def __init__(self, application: int = OPUS_APPLICATION_AUDIO): + # Set first so __del__ is safe even if _get_lib() raises below. + self._state: int = 0 + self._lib: ctypes.CDLL = _get_lib() + self.application = application + """ The Opus application type the encoder was created with. """ + + error = ctypes.c_int() + state: int = self._lib.opus_encoder_create( + ctypes.c_int(SAMPLE_RATE), + ctypes.c_int(CHANNELS), + ctypes.c_int(application), + ctypes.byref(error), + ) + + _check(error.value) + self._state = state + + def __del__(self) -> None: + self.cleanup() + + def encode(self, pcm: bytes, frame_size: int = SAMPLES_PER_FRAME) -> bytes: + """ + Encode a single frame of PCM audio into an Opus packet. + + Parameters + ---------- + pcm: + The raw signed 16-bit little-endian stereo PCM data. + frame_size: + The number of samples per channel in the frame. + + Returns + ------- + The encoded Opus packet. + + Raises + ------ + OpusError + If libopus fails to encode the frame. + """ + max_data_bytes = len(pcm) + pcm_ptr = _as_int16_ptr(pcm) + output = (ctypes.c_ubyte * max_data_bytes)() + + result: int = self._lib.opus_encode( + self._state, + pcm_ptr, + ctypes.c_int(frame_size), + ctypes.cast(output, ctypes.POINTER(ctypes.c_ubyte)), + ctypes.c_int32(max_data_bytes), + ) + + _check(result) + + return bytes(output[:result]) + + def cleanup(self) -> None: + """ Free the underlying libopus encoder. """ + if self._state: + self._lib.opus_encoder_destroy(self._state) + self._state = 0 + + +class Decoder: + """ A libopus decoder configured for Discord voice (48kHz, stereo). """ + + def __init__(self): + # Set first so __del__ is safe even if _get_lib() raises below. + self._state: int = 0 + self._lib: ctypes.CDLL = _get_lib() + + error = ctypes.c_int() + state: int = self._lib.opus_decoder_create( + ctypes.c_int(SAMPLE_RATE), + ctypes.c_int(CHANNELS), + ctypes.byref(error), + ) + + _check(error.value) + self._state = state + + def __del__(self) -> None: + self.cleanup() + + @staticmethod + def packet_get_nb_frames(data: bytes) -> int: + """ + Return the number of frames in an Opus packet. + + Parameters + ---------- + data: + The Opus packet to inspect. + + Returns + ------- + The number of frames the packet contains. + """ + lib = _get_lib() + data_ptr = _as_ubyte_ptr(data) + return _check(lib.opus_packet_get_nb_frames(data_ptr, ctypes.c_int(len(data)))) + + @staticmethod + def packet_get_samples_per_frame(data: bytes) -> int: + """ + Return the number of samples per frame for an Opus packet. + + Parameters + ---------- + data: + The Opus packet to inspect. + + Returns + ------- + The number of samples per channel in each frame. + """ + lib = _get_lib() + data_ptr = _as_ubyte_ptr(data) + return _check(lib.opus_packet_get_samples_per_frame(data_ptr, ctypes.c_int(SAMPLE_RATE))) + + def decode(self, data: bytes | None, *, fec: bool = False) -> bytes: + """ + Decode an Opus packet into PCM audio. + + Parameters + ---------- + data: + The Opus packet to decode, or ``None`` to perform packet-loss + concealment for a single 20ms frame. + fec: + Whether to decode using forward error correction. + + Returns + ------- + The decoded signed 16-bit little-endian stereo PCM data. + + Raises + ------ + OpusError + If libopus fails to decode the packet. + """ + if data is None: + frame_size = SAMPLES_PER_FRAME + data_ptr: "ctypes._Pointer[ctypes.c_ubyte] | None" = None + data_len = 0 + else: + frames = self.packet_get_nb_frames(data) + samples_per_frame = self.packet_get_samples_per_frame(data) + frame_size = frames * samples_per_frame + data_ptr = _as_ubyte_ptr(data) + data_len = len(data) + + pcm = (ctypes.c_int16 * (frame_size * CHANNELS))() + + result: int = self._lib.opus_decode( + self._state, + data_ptr, + ctypes.c_int32(data_len), + ctypes.cast(pcm, ctypes.POINTER(ctypes.c_int16)), + ctypes.c_int(frame_size), + ctypes.c_int(1 if fec else 0), + ) + + _check(result) + + return bytes(bytearray(pcm)[: result * CHANNELS * SAMPLE_SIZE]) + + def cleanup(self) -> None: + """ Free the underlying libopus decoder. """ + if self._state: + self._lib.opus_decoder_destroy(self._state) + self._state = 0 diff --git a/discord_http/voice/player.py b/discord_http/voice/player.py new file mode 100644 index 0000000..5c6c6b2 --- /dev/null +++ b/discord_http/voice/player.py @@ -0,0 +1,563 @@ +import abc +import asyncio +import io +import logging +import os +import shlex +import shutil + +from array import array +from collections import deque +from collections.abc import AsyncIterable, Callable +from typing import TYPE_CHECKING + +from .oggparse import _HEADER_STRUCT, _OGG_MAGIC, OggPage +from .opus import OPUS_SILENCE + +if TYPE_CHECKING: + from .client import VoiceClient + +__all__ = ( + "AudioPlayer", + "AudioSource", + "AudioSourceInput", + "FFmpegOpusAudio", + "FFmpegPCMAudio", + "PCMAudio", + "PCMVolumeTransformer", +) + +_log = logging.getLogger(__name__) + + +# The size of a single 20ms PCM frame, in bytes (48kHz, stereo, s16le). +FRAME_SIZE = 3840 + +# The number of bytes pulled from ffmpeg stdout per read when parsing Ogg/Opus. +_OGG_READ_CHUNK = 8192 + +# The signed 16-bit value range, used when clamping scaled PCM samples. +_INT16_MIN = -32768 +_INT16_MAX = 32767 + + +class AudioSource(abc.ABC): + """ An abstract audio source yielding one 20ms frame per :meth:`read` (Opus packet or 3840-byte s16le PCM). """ + + @abc.abstractmethod + async def read(self) -> bytes: + """ Read the next 20ms frame (Opus packet or 3840-byte PCM), or empty bytes at end of stream. """ + raise NotImplementedError + + def is_opus(self) -> bool: + """ Whether :meth:`read` yields pre-encoded Opus packets rather than PCM. """ + return False + + def cleanup(self) -> None: + """ Release any resources held by the source. """ + return + + +class PCMAudio(AudioSource): + """ An audio source that reads raw s16le 48kHz stereo PCM frames from a binary stream. """ + + def __init__(self, stream: io.IOBase) -> None: + self.stream = stream + + async def read(self) -> bytes: + """ Read one 3840-byte PCM frame, or empty bytes at end of stream. """ + ret = self.stream.read(FRAME_SIZE) + if len(ret) != FRAME_SIZE: + return b"" + return ret + + +class PCMVolumeTransformer(AudioSource): + """ A wrapper that scales the volume of a PCM (non-Opus) audio source using the stdlib :mod:`array` module. """ + + def __init__(self, original: AudioSource, volume: float = 1.0) -> None: + if not isinstance(original, AudioSource): + raise TypeError(f"Expected AudioSource, got {type(original).__name__}") + if original.is_opus(): + raise ValueError("PCMVolumeTransformer only supports non-Opus sources") + + self.original = original + self._volume = max(volume, 0.0) + + @property + def volume(self) -> float: + """ The volume multiplier, where ``1.0`` is unchanged. """ + return self._volume + + @volume.setter + def volume(self, value: float) -> None: + self._volume = max(value, 0.0) + + async def read(self) -> bytes: + """ Read one frame from the wrapped source with volume applied. """ + data = await self.original.read() + if not data: + return b"" + + samples = array("h") + samples.frombytes(data) + for i, sample in enumerate(samples): + scaled = int(sample * self._volume) + samples[i] = min(max(scaled, _INT16_MIN), _INT16_MAX) + + return samples.tobytes() + + def cleanup(self) -> None: + """ Clean up the wrapped source. """ + self.original.cleanup() + + +class _FFmpegAudio(AudioSource): + """ A base audio source backed by an ``ffmpeg`` subprocess launched lazily on first :meth:`read`. """ + + def __init__( + self, + source: str | io.IOBase | AsyncIterable[bytes], + *, + args: list[str], + before_args: list[str] | None = None, + executable: str = "ffmpeg", + pipe: bool = False, + ) -> None: + if shutil.which(executable) is None: + raise FileNotFoundError(f"ffmpeg executable {executable!r} was not found on PATH") + + self._source = source + self._executable = executable + self._pipe = pipe + self._args = args + self._before_args = before_args or [] + + self._process: asyncio.subprocess.Process | None = None + self._stdin_task: asyncio.Task[None] | None = None + self._stdout: asyncio.StreamReader | None = None + self._reap_task: asyncio.Task[None] | None = None + + async def _spawn(self) -> None: + """ Launch the ffmpeg subprocess and start the stdin pump if piping. """ + stdin = asyncio.subprocess.PIPE if self._pipe else asyncio.subprocess.DEVNULL + input_arg = "pipe:0" if self._pipe else self._source + + if not isinstance(input_arg, str): + # Non-pipe sources must be a path or URL string. + raise TypeError(f"Expected str source for non-piped ffmpeg, got {type(input_arg).__name__}") + + self._process = await asyncio.create_subprocess_exec( + self._executable, + *self._before_args, + "-i", + input_arg, + *self._args, + stdin=stdin, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + ) + self._stdout = self._process.stdout + + if self._pipe: + self._stdin_task = asyncio.create_task(self._pump_stdin()) + + async def _pump_stdin(self) -> None: + """ Copy the piped source into ffmpeg stdin, then close it. """ + process = self._process + if process is None or process.stdin is None: + return + + stdin = process.stdin + try: + if isinstance(self._source, AsyncIterable): + async for chunk in self._source: + stdin.write(chunk) + await stdin.drain() + elif isinstance(self._source, io.IOBase): + while True: + chunk = self._source.read(_OGG_READ_CHUNK) + if not chunk: + break + stdin.write(chunk) + await stdin.drain() + except (BrokenPipeError, ConnectionResetError): + # ffmpeg may exit early (e.g. on stop); nothing more to feed. + pass + finally: + try: + stdin.close() + except (BrokenPipeError, ConnectionResetError): + pass + + def cleanup(self) -> None: + """ Terminate the ffmpeg subprocess and cancel the stdin pump. """ + if self._stdin_task is not None and not self._stdin_task.done(): + self._stdin_task.cancel() + self._stdin_task = None + + process = self._process + if process is not None: + if process.returncode is None: + try: + process.kill() + except ProcessLookupError: + pass + + # Close the subprocess transport so its stdio pipe transports are + # released deterministically. On the Windows Proactor event loop an + # unclosed pipe transport otherwise triggers a ResourceWarning + # ("unclosed transport" / "I/O operation on closed pipe") when it is + # finalized by the garbage collector. + transport = getattr(process, "_transport", None) + if transport is not None: + try: + transport.close() + except Exception: + pass + + # Reap the process so the OS releases it and the pipe transports + # finish closing. ``cleanup`` is synchronous, so schedule the wait on + # the running loop when there is one (there is none during a hard + # interpreter shutdown, where closing the transport above suffices). + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + if loop is not None: + # Keep a reference so the fire-and-forget reaping task is not + # garbage-collected before it completes. + self._reap_task = loop.create_task(self._reap_process(process)) + + self._process = None + self._stdout = None + + @staticmethod + async def _reap_process(process: "asyncio.subprocess.Process") -> None: + """ Await the ffmpeg subprocess so it is fully reaped after termination. """ + try: + await process.wait() + except Exception: + pass + + +class FFmpegPCMAudio(_FFmpegAudio): + """ An audio source that transcodes input to s16le 48kHz stereo PCM with ``ffmpeg`` (libopus needed to encode). """ + + def __init__( + self, + source: str | io.IOBase | AsyncIterable[bytes], + *, + before_options: str | None = None, + options: str | None = None, + pipe: bool = False, + executable: str = "ffmpeg", + ) -> None: + before_args = shlex.split(before_options) if before_options is not None else None + + args = ["-f", "s16le", "-ar", "48000", "-ac", "2", "-loglevel", "warning"] + if options is not None: + args.extend(shlex.split(options)) + args.append("pipe:1") + + super().__init__(source, args=args, before_args=before_args, executable=executable, pipe=pipe) + + async def read(self) -> bytes: + """ Read one 3840-byte PCM frame from ffmpeg, or empty bytes at EOF. """ + if self._stdout is None: + await self._spawn() + + assert self._stdout is not None + try: + return await self._stdout.readexactly(FRAME_SIZE) + except asyncio.IncompleteReadError: + # End of stream: discard any trailing partial frame and signal EOF. + # The contract requires exactly FRAME_SIZE bytes or empty bytes; a + # short PCM frame would be mis-encoded by libopus downstream. + return b"" + + +class FFmpegOpusAudio(_FFmpegAudio): + """ An audio source that encodes input to Opus with ``ffmpeg``, extracting raw packets from its Ogg/Opus stream. """ + + def __init__( + self, + source: str | io.IOBase | AsyncIterable[bytes], + *, + bitrate: int = 128, + before_options: str | None = None, + options: str | None = None, + pipe: bool = False, + executable: str = "ffmpeg", + ) -> None: + before_args = shlex.split(before_options) if before_options is not None else None + + args = [ + "-c:a", "libopus", + "-f", "opus", + "-ar", "48000", + "-ac", "2", + "-b:a", f"{bitrate}k", + "-loglevel", "warning", + ] + if options is not None: + args.extend(shlex.split(options)) + args.append("pipe:1") + + super().__init__(source, args=args, before_args=before_args, executable=executable, pipe=pipe) + + self._buffer = bytearray() + self._partial = bytearray() + self._packets: deque[bytes] = deque() + self._eof = False + + async def _fill_buffer(self) -> bool: + """ Read one chunk from ffmpeg stdout into the buffer, returning ``False`` at end of stdout. """ + assert self._stdout is not None + chunk = await self._stdout.read(_OGG_READ_CHUNK) + if not chunk: + self._eof = True + return False + + self._buffer.extend(chunk) + return True + + def _drain_buffer(self) -> None: + """ Parse every complete Ogg page currently held in the buffer. """ + while True: + index = self._buffer.find(_OGG_MAGIC) + if index < 0: + break + + try: + # The slice starts just after the magic, matching what OggPage + # expects. OggPage raises ValueError while the page is still + # truncated, in which case we wait for more ffmpeg output. + page = OggPage(io.BytesIO(self._buffer[index + 4:])) + except ValueError: + break + + page_end = index + 4 + _HEADER_STRUCT.size + len(page.segtable) + len(page.data) + + for chunk, complete in page.iter_packets(): + self._partial.extend(chunk) + if complete: + packet = bytes(self._partial) + self._partial.clear() + if not packet.startswith((b"OpusHead", b"OpusTags")): + self._packets.append(packet) + + del self._buffer[:page_end] + + async def read(self) -> bytes: + """ Read one Opus packet from ffmpeg's Ogg stream, or empty bytes at EOF. """ + if self._stdout is None: + await self._spawn() + + while not self._packets: + self._drain_buffer() + if self._packets: + break + if self._eof: + return b"" + await self._fill_buffer() + + return self._packets.popleft() + + def is_opus(self) -> bool: + """ Whether frames are Opus packets (always ``True`` for this source). """ + return True + + +class AudioPlayer: + """ A drift-corrected :class:`asyncio.Task` player that streams an :class:`AudioSource` to a voice client. """ + + DELAY: float = 0.02 + + def __init__( + self, + source: AudioSource, + voice_client: "VoiceClient", + *, + after: Callable[[Exception | None], object] | None = None, + ) -> None: + self.source = source + self.voice_client = voice_client + self.after = after + + self._loop = voice_client.loop + self._task: asyncio.Task[None] | None = None + self._resumed = asyncio.Event() + self._resumed.set() + self._end = asyncio.Event() + self._error: Exception | None = None + + def start(self) -> None: + """ Schedule the playback task on the voice client's event loop. """ + if self._task is not None: + raise RuntimeError("Player has already been started") + self._task = self._loop.create_task(self._run()) + + async def _run(self) -> None: + """ Drive the playback loop with drift-corrected pacing. """ + try: + await self.voice_client.speak(True) + + start = self._loop.time() + count = 0 + + while not self._end.is_set(): + if not self._resumed.is_set(): + await self._resumed.wait() + # Re-anchor pacing after a pause so we do not burst frames. + start = self._loop.time() + count = 0 + + source = self.source + data = await source.read() + + if source is not self.source: + # set_source() swapped the source while this read was in + # flight; the old source may have been torn down mid-read, + # making a truncated read look like EOF. Discard the stale + # result and re-anchor pacing on the new source instead. + start = self._loop.time() + count = 0 + continue + + if not data: + break + + self.voice_client.send_audio_packet(data, encode=not source.is_opus()) + + count += 1 + deadline = start + count * self.DELAY + await asyncio.sleep(max(0.0, deadline - self._loop.time())) + except Exception as exc: + self._error = exc + finally: + try: + await self._cleanup() + except asyncio.CancelledError: + _log.warning( + f"Audio player cleanup for guild {self.voice_client.guild_id} " + "was interrupted; trailing silence and speaking-off may be skipped" + ) + raise + + async def _cleanup(self) -> None: + """ Flush silence, stop speaking, clean up and invoke ``after``. """ + # Mark the player as finished so ``is_playing()``/``is_paused()`` report + # correctly after natural EOF. Idempotent: ``stop()`` may have set it. + self._end.set() + + # Only flush silence and clear the speaking flag while this player is + # still the client's current player (or the client has none). When + # ``play()`` replaced us with a new player, our teardown runs + # concurrently with its startup; sending speaking-off here would race + # the new player's speaking-on and could leave the indicator stuck off. + current = self.voice_client._player + if current is self or current is None: + try: + for _ in range(5): + self.voice_client.send_audio_packet(OPUS_SILENCE, encode=False) + except Exception: + _log.exception("Failed to send trailing silence frames") + + try: + await self.voice_client.speak(False) + except Exception: + _log.exception("Failed to disable speaking") + + self.source.cleanup() + + if self.after is not None: + try: + self.after(self._error) + except Exception: + _log.exception("Error calling the after callback") + elif self._error is not None: + _log.exception("Exception in audio player", exc_info=self._error) + + def stop(self) -> None: + """ Stop playback as soon as possible and resume any paused loop. """ + self._end.set() + self._resumed.set() + if self._task is not None: + self._task.cancel() + + def pause(self) -> None: + """ Pause playback, halting reads until :meth:`resume` is called. """ + self._resumed.clear() + + def resume(self) -> None: + """ Resume playback after a :meth:`pause`. """ + self._resumed.set() + + def is_playing(self) -> bool: + """ Whether audio is currently playing (running and not paused). """ + return not self._end.is_set() and self._resumed.is_set() + + def is_paused(self) -> bool: + """ Whether playback is paused (running but paused). """ + return not self._end.is_set() and not self._resumed.is_set() + + def set_source(self, source: AudioSource) -> None: + """ + Hot-swap the audio source without interrupting the player task. + + Parameters + ---------- + source: + The new audio source to read from. + """ + self.pause() + self.source.cleanup() + self.source = source + self.resume() + + +AudioSourceInput = AudioSource | str | os.PathLike | bytes | bytearray | memoryview | io.IOBase | AsyncIterable[bytes] +""" The set of inputs accepted as audio sources by :meth:`VoiceClient.play`. """ + + +def _resolve_source(audio: object) -> AudioSource: + """ + Coerce arbitrary audio input into an :class:`AudioSource`. + + Parameters + ---------- + audio: + One of: an :class:`AudioSource` (returned as-is); a ``str`` or + :class:`os.PathLike` path/URL; raw ``bytes``/``bytearray``/``memoryview``; + a readable :class:`io.IOBase` stream; or an :class:`~collections.abc.AsyncIterable` + of ``bytes``. The latter four are decoded by ffmpeg into Opus. + + Returns + ------- + AudioSource + A source ready to be played. + + Raises + ------ + TypeError + If ``audio`` is not a supported type. + FileNotFoundError + If ffmpeg is required but not found on ``PATH``. + """ + if isinstance(audio, AudioSource): + return audio + + if isinstance(audio, (str, os.PathLike)): + return FFmpegOpusAudio(os.fspath(audio)) + + if isinstance(audio, (bytes, bytearray, memoryview)): + return FFmpegOpusAudio(io.BytesIO(bytes(audio)), pipe=True) + + if isinstance(audio, io.IOBase): + return FFmpegOpusAudio(audio, pipe=True) + + if isinstance(audio, AsyncIterable): + return FFmpegOpusAudio(audio, pipe=True) + + raise TypeError(f"Unsupported audio source type: {type(audio).__name__}") diff --git a/discord_http/voice/receiver.py b/discord_http/voice/receiver.py new file mode 100644 index 0000000..c6751c5 --- /dev/null +++ b/discord_http/voice/receiver.py @@ -0,0 +1,297 @@ +import logging +import struct + +from typing import TYPE_CHECKING + +from . import opus +from .opus import Decoder +from .sinks import VoiceData + +if TYPE_CHECKING: + from .client import VoiceClient + from .sinks import AudioSink + +__all__ = ( + "VoiceReceiver", +) + +_log = logging.getLogger(__name__) + + +# The fixed-length portion of an RTP header is 12 bytes; the SSRC is the final +# 32-bit big-endian field, occupying bytes 8..12. +_RTP_HEADER_LENGTH = 12 +_SSRC_OFFSET = 8 + +# The RTP timestamp is a 32-bit big-endian field occupying bytes 4..8. +_TIMESTAMP_OFFSET = 4 + +# The RTP sequence number is a 16-bit big-endian field occupying bytes 2..4. +_SEQUENCE_OFFSET = 2 + + +class VoiceReceiver: + """ Consumes incoming RTP voice packets and dispatches audio to an :class:`AudioSink`. """ + + def __init__(self, voice_client: "VoiceClient") -> None: + """ + Create a receiver bound to a voice client. + + Parameters + ---------- + voice_client: + The voice client this receiver belongs to, used to reach the + connection (encryptor, DAVE hooks) and event loop. + """ + self.voice_client = voice_client + """ The voice client this receiver belongs to. """ + + self.sink: "AudioSink | None" = None + """ The sink currently receiving audio, or ``None`` when not listening. """ + + self._ssrc_map: dict[int, int] = {} + """ Maps an RTP SSRC to the user ID it belongs to. """ + + # Lazily-created per-SSRC Opus decoders. Only populated when the active + # sink wants PCM, since Opus passthrough never needs to decode. + self._decoders: dict[int, Decoder] = {} + + # Per-SSRC last seen RTP sequence number, used for lightweight + # packet-loss concealment when decoding to PCM. + self._last_seq: dict[int, int] = {} + + # Whether the missing-libopus warning has already been emitted, so the + # synchronous UDP callback does not spam the log on every packet. + self._warned_no_opus = False + + # Count of packets dropped because DAVE was active but the SSRC was + # not yet mapped to a user, used to rate-limit the debug log. + self._dave_unmapped_drops = 0 + + def start(self, sink: "AudioSink") -> None: + """ + Begin listening, dispatching received audio to ``sink``. + + Parameters + ---------- + sink: + The sink to receive decoded PCM or raw Opus audio. + """ + # Tear down any in-progress session so its sink is cleaned up and stale + # per-SSRC decoder/sequence state does not leak into the new sink. + if self.sink is not None: + self.stop() + + self.sink = sink + + def stop(self) -> None: + """ Stop listening and release any per-SSRC decoders and the sink. """ + sink = self.sink + self.sink = None + + for decoder in self._decoders.values(): + decoder.cleanup() + + self._decoders.clear() + self._last_seq.clear() + + if sink is not None: + try: + sink.cleanup() + except Exception: + _log.exception("Error while cleaning up audio sink") + + def reset(self) -> None: + """ + Drop all SSRC-keyed state for a fresh voice session. + + Called on voice READY, where a fresh session has just allocated new + SSRCs. Separate from :meth:`stop` because ``stop()`` also detaches and + finalizes the sink. A reconnect must keep listening with the existing + sink while forgetting mappings, decoders and sequence state tied to + SSRCs from the previous session. + """ + for decoder in self._decoders.values(): + decoder.cleanup() + + self._ssrc_map.clear() + self._decoders.clear() + self._last_seq.clear() + self._dave_unmapped_drops = 0 + + def is_listening(self) -> bool: + """ + Whether a sink is currently attached. + + Returns + ------- + ``True`` if listening, ``False`` otherwise. + """ + return self.sink is not None + + def add_ssrc(self, ssrc: int, user_id: int) -> None: + """ + Associate an RTP SSRC with a user ID. + + Parameters + ---------- + ssrc: + The RTP synchronisation source identifier. + user_id: + The user ID that owns the SSRC. + """ + self._ssrc_map[ssrc] = user_id + + def remove_user(self, user_id: int) -> None: + """ + Remove every SSRC mapping and decoder belonging to a user. + + Parameters + ---------- + user_id: + The user ID to forget. + """ + stale = [ssrc for ssrc, uid in self._ssrc_map.items() if uid == user_id] + + for ssrc in stale: + del self._ssrc_map[ssrc] + self._last_seq.pop(ssrc, None) + + decoder = self._decoders.pop(ssrc, None) + if decoder is not None: + decoder.cleanup() + + def _get_decoder(self, ssrc: int) -> Decoder: + """ + Return the per-SSRC decoder, creating it on first use. + + Parameters + ---------- + ssrc: + The RTP synchronisation source identifier to decode for. + + Returns + ------- + The decoder dedicated to ``ssrc``. + """ + decoder = self._decoders.get(ssrc) + if decoder is None: + decoder = Decoder() + self._decoders[ssrc] = decoder + + return decoder + + def unpack(self, packet: bytes) -> None: + """ + Decrypt, decode and dispatch a single received RTP packet. + + This is called synchronously from the UDP datagram callback, so it must + never raise: every failure is logged and swallowed. + + Parameters + ---------- + packet: + The raw RTP packet as received from the voice UDP socket. + """ + sink = self.sink + if sink is None: + return + + if len(packet) < _RTP_HEADER_LENGTH: + return + + ssrc = struct.unpack_from(">I", packet, _SSRC_OFFSET)[0] + timestamp = struct.unpack_from(">I", packet, _TIMESTAMP_OFFSET)[0] + sequence = struct.unpack_from(">H", packet, _SEQUENCE_OFFSET)[0] + user_id = self._ssrc_map.get(ssrc) + + connection = self.voice_client.connection + + encryptor = connection.encryptor + if encryptor is None: + # No session key yet (or already torn down); nothing decryptable. + return + + try: + payload = encryptor.decrypt(packet) + except Exception: + _log.exception("Failed to transport-decrypt incoming voice packet") + return + + # DAVE end-to-end decryption, applied whenever a session is active. + # Decrypting requires knowing who the sender is, and RTP (UDP) has no + # ordering guarantee against the SPEAKING event (voice websocket) that + # maps the SSRC to a user. If the mapping has not arrived yet, drop the + # packet rather than passing the still-encrypted payload along as if it + # were plain Opus; losing a few leading frames is acceptable. + if connection.can_encrypt(): + if user_id is None: + self._dave_unmapped_drops += 1 + if self._dave_unmapped_drops == 1 or self._dave_unmapped_drops % 100 == 0: + _log.debug( + f"Dropped {self._dave_unmapped_drops} DAVE-encrypted packet(s) " + f"from not-yet-mapped SSRC(s), latest ssrc={ssrc}" + ) + return + + try: + payload = connection.dave_decrypt_opus(user_id, payload) + except Exception: + _log.exception("Failed to DAVE-decrypt incoming voice packet") + return + + if sink.wants_opus(): + data = VoiceData(user=user_id, pcm=None, opus=payload, timestamp=timestamp, ssrc=ssrc) + else: + pcm = self._decode_pcm(ssrc, sequence, payload) + if pcm is None: + return + data = VoiceData(user=user_id, pcm=pcm, opus=None, timestamp=timestamp, ssrc=ssrc) + + try: + sink.write(user_id, data) + except Exception: + _log.exception("Error in audio sink while writing received voice data") + + def _decode_pcm(self, ssrc: int, sequence: int, payload: bytes) -> bytes | None: + """ + Decode an Opus payload to PCM, applying lightweight packet-loss concealment. + + Parameters + ---------- + ssrc: + The RTP synchronisation source identifier of the sender. + sequence: + The RTP sequence number of the packet, used to detect gaps. + payload: + The Opus payload to decode. + + Returns + ------- + The decoded signed 16-bit little-endian stereo PCM, or ``None`` when + libopus is unavailable and the packet must be dropped. + """ + if not opus.is_loaded(): + # PCM was requested but libopus is missing. Log once and drop rather + # than raising, so the synchronous UDP callback never crashes. + if not self._warned_no_opus: + self._warned_no_opus = True + _log.warning("libopus is not loaded; dropping received voice (PCM decoding unavailable)") + return None + + try: + decoder = self._get_decoder(ssrc) + + # Detect a sequence gap and conceal a single lost frame before + # decoding the packet we actually received. RTP sequence numbers are + # 16-bit and wrap, so compare modulo 2**16. + last = self._last_seq.get(ssrc) + if last is not None and (sequence - last) & 0xFFFF > 1: + decoder.decode(None) + + self._last_seq[ssrc] = sequence + + return decoder.decode(payload) + except Exception: + _log.exception("Failed to Opus-decode received voice packet") + return None diff --git a/discord_http/voice/sinks.py b/discord_http/voice/sinks.py new file mode 100644 index 0000000..f103005 --- /dev/null +++ b/discord_http/voice/sinks.py @@ -0,0 +1,196 @@ +import abc +import io +import logging +import os +import wave + +from collections.abc import Callable +from dataclasses import dataclass + +__all__ = ( + "AudioSink", + "CallbackSink", + "VoiceData", + "WaveSink", +) + +_log = logging.getLogger(__name__) + + +@dataclass(slots=True) +class VoiceData: + """ Represents a single chunk of received voice audio for one speaker. """ + + user: int | None + """ The user ID this audio belongs to, or ``None`` if unknown. """ + + pcm: bytes | None + """ The decoded 48kHz 16-bit stereo PCM payload, if available. """ + + opus: bytes | None + """ The raw Opus payload, if available. """ + + timestamp: int + """ The RTP timestamp of the packet this data came from. """ + + ssrc: int + """ The RTP SSRC of the sender this data came from. """ + + +class AudioSink(abc.ABC): + """ Abstract base class for consumers of received voice audio. """ + + def wants_opus(self) -> bool: + """ + Whether this sink wants raw Opus payloads instead of decoded PCM. + + When ``False`` (the default) the receiver decodes packets to PCM + before handing them to :meth:`write`. + + Returns + ------- + ``True`` if the sink consumes Opus, ``False`` for PCM + """ + return False + + @abc.abstractmethod + def write(self, user: int | None, data: VoiceData) -> None: + """ + Consume a single chunk of received voice audio. + + Parameters + ---------- + user: + The user ID the audio belongs to, or ``None`` if unknown + data: + The voice data container holding the PCM and/or Opus payload + """ + raise NotImplementedError + + def cleanup(self) -> None: + """ Finalize the sink, flushing and releasing any held resources. """ + return + + +class CallbackSink(AudioSink): + """ Audio sink that forwards every received chunk to a callback. """ + + def __init__( + self, + callback: Callable[[int | None, VoiceData], object], + *, + opus: bool = False + ) -> None: + """ + Create a sink that forwards received audio to a callback. + + Parameters + ---------- + callback: + The callable invoked as ``callback(user, data)`` for each chunk + opus: + Whether to request raw Opus payloads instead of decoded PCM + """ + self.callback = callback + self.opus = opus + + def wants_opus(self) -> bool: + """ + Whether this sink wants raw Opus payloads instead of decoded PCM. + + Returns + ------- + The value of the ``opus`` flag passed at construction + """ + return self.opus + + def write(self, user: int | None, data: VoiceData) -> None: + """ + Forward the received audio chunk to the callback. + + Parameters + ---------- + user: + The user ID the audio belongs to, or ``None`` if unknown + data: + The voice data container holding the PCM and/or Opus payload + """ + self.callback(user, data) + + +class WaveSink(AudioSink): + """ + Audio sink that writes received PCM to a single 48kHz 16-bit stereo WAV file. + + Audio from all speakers is appended to the one stream in arrival order; it + is **not** mixed. With a single speaker this produces a normal recording, + but simultaneous speakers will have their chunks interleaved back-to-back + and sound garbled. To handle overlapping speakers, separate the audio per + user first (for example with a :class:`CallbackSink` that routes each + ``user`` to its own file) and mix the results afterwards. + """ + + def __init__(self, destination: str | os.PathLike | io.IOBase) -> None: + """ + Create a sink that writes received PCM to a WAV file. + + Parameters + ---------- + destination: + A file path or writable binary stream to receive the WAV data + """ + self.destination = destination + self._file: wave.Wave_write | None = None + self._finalized = False + + def wants_opus(self) -> bool: + """ + Whether this sink wants raw Opus payloads instead of decoded PCM. + + Returns + ------- + Always ``False`` as the WAV file stores PCM + """ + return False + + def _ensure_open(self) -> wave.Wave_write: + """ Open the wave file lazily, configuring it for 48kHz 16-bit stereo. """ + if self._finalized: + raise RuntimeError("Cannot write to a WaveSink after it has been finalized") + + file = self._file + if file is None: + destination = os.fspath(self.destination) if isinstance(self.destination, os.PathLike) else self.destination + file = wave.open(destination, "wb") # type: ignore[arg-type] # noqa: SIM115 + file.setnchannels(2) + file.setsampwidth(2) + file.setframerate(48000) + self._file = file + return file + + def write(self, user: int | None, data: VoiceData) -> None: # noqa: ARG002 + """ + Append the chunk's PCM payload to the WAV file. + + Parameters + ---------- + user: + The user ID the audio belongs to, or ``None`` if unknown (unused; + chunks from all speakers are appended to the same WAV stream in + arrival order, without mixing) + data: + The voice data container holding the PCM payload + """ + if data.pcm is None: + return + self._ensure_open().writeframes(data.pcm) + + def cleanup(self) -> None: + """ Finalize the WAV file, writing headers and closing the stream. """ + if self._finalized: + return + + self._finalized = True + if self._file is not None: + self._file.close() + self._file = None diff --git a/discord_http/voice/socket.py b/discord_http/voice/socket.py new file mode 100644 index 0000000..3f86b76 --- /dev/null +++ b/discord_http/voice/socket.py @@ -0,0 +1,488 @@ +import asyncio +import logging +import struct +import time + +import orjson + +from aiohttp import ClientSession, ClientWebSocketResponse, WSMsgType +from collections import deque +from collections.abc import Coroutine +from typing import TYPE_CHECKING, Any + +from ..enums import BaseEnum +from .enums import VoiceOpType + +if TYPE_CHECKING: + from .connection import VoiceConnection + +__all__ = ("VoiceCloseCode", "VoiceSocket") + +_log = logging.getLogger(__name__) + + +class VoiceCloseCode(BaseEnum): + """ The voice gateway websocket close codes that govern reconnect behaviour. """ + + normal = 1000 + going_away = 1001 + session_invalid = 4006 + disconnected = 4014 + voice_server_crashed = 4015 + rate_limited = 4021 + call_terminated = 4022 + + +class VoiceSocket: + """ The voice gateway (v8) websocket: handshake, heartbeating, latency, and JSON/binary frame dispatch. """ + + def __init__(self, connection: "VoiceConnection"): + self.connection: "VoiceConnection" = connection + """ The voice connection that owns this socket. """ + + self.ws: ClientWebSocketResponse | None = None + """ The underlying websocket connection, if open. """ + + self.seq_ack: int = -1 + """ The last sequence number received from the voice gateway. """ + + self._closing: bool = False + self._resuming: bool = False + + self._heartbeat_interval: float = 0.0 + self._heartbeat_task: asyncio.Task | None = None + self._receive_task: asyncio.Task | None = None + self._dispatch_tasks: set[asyncio.Task] = set() + + self._last_send: float = 0.0 + self._heartbeat_ack_pending = False + self._latencies: deque[float] = deque(maxlen=20) + + @property + def latency(self) -> float: + """ The latency of the most recent heartbeat, in seconds, or ``inf`` if unknown. """ + if not self._latencies: + return float("inf") + return self._latencies[-1] + + @property + def average_latency(self) -> float: + """ The average latency over the last few heartbeats, in seconds, or ``inf`` if unknown. """ + if not self._latencies: + return float("inf") + return sum(self._latencies) / len(self._latencies) + + @property + def session(self) -> ClientSession: + """ The shared aiohttp session from the bot's HTTP client, reused for the voice websocket. """ + session = self.connection.voice_client.bot.state.http.session + if session is None: + raise RuntimeError("HTTP session is not available; the client must be running to open a voice socket") + return session + + async def connect(self, *, resume: bool = False) -> None: + """ + Open the voice websocket and start the receive loop. + + Parameters + ---------- + resume: + Whether to RESUME (op 7) an existing session rather than IDENTIFY (op 0). + """ + self._closing = False + self._resuming = resume + endpoint = self.connection.endpoint + self.ws = await self.session.ws_connect(f"wss://{endpoint}/?v=8") + + self._receive_task = asyncio.create_task( + self._receive_loop(), + name=f"discord.http/voice/socket-{self.connection.guild_id}/receive" + ) + + async def _receive_loop(self) -> None: + """ Continuously receive frames and dispatch them; never blocks on handlers. """ + ws = self.ws + if ws is None: + return + + close_code: int | None = None + + try: + while True: + msg = await ws.receive() + + if msg.type is WSMsgType.TEXT: + self._dispatch_text(msg.data) + + elif msg.type is WSMsgType.BINARY: + self._dispatch_binary(msg.data) + + elif msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING, WSMsgType.CLOSED): + # Discord's close reason (msg.extra) is invaluable for diagnosing + # voice failures (e.g. "E2EE/DAVE protocol required" for 4017), + # so surface it alongside the code. + _log.debug( + f"Voice socket for guild {self.connection.guild_id} received close frame " + f"(code={msg.data!r}, reason={msg.extra!r})" + ) + break + + elif msg.type is WSMsgType.ERROR: + _log.debug(f"Voice socket for guild {self.connection.guild_id} received error: {msg.data}") + break + + except asyncio.CancelledError: + raise + + except Exception as exc: + _log.debug(f"Voice socket for guild {self.connection.guild_id} receive loop ended", exc_info=exc) + + close_code = ws.close_code + + if not self._closing: + self.connection._on_socket_closed(close_code) + + def _request_close(self) -> None: + """ Mark the socket as intentionally closing so the receive loop suppresses reconnect. """ + self._closing = True + + def _dispatch_text(self, raw: str | bytes) -> None: + """ + Parse and dispatch a text frame by its voice opcode. + + Parameters + ---------- + raw: + The raw JSON text frame received from the voice gateway. + """ + payload: dict = orjson.loads(raw) + + seq = payload.get("seq") + if seq is not None: + self.seq_ack = seq + + op = payload.get("op") + data: dict = payload.get("d") or {} + + try: + voice_op = VoiceOpType(op) + except ValueError: + _log.debug(f"Voice socket for guild {self.connection.guild_id} received unknown op {op}") + return + + match voice_op: + case VoiceOpType.hello: + self._heartbeat_interval = float(data["heartbeat_interval"]) / 1000 + self._schedule(self._handle_hello()) + + case VoiceOpType.ready: + self._schedule(self.connection.on_ready(data)) + + case VoiceOpType.session_description: + self._schedule(self.connection.on_session_description(data)) + + case VoiceOpType.speaking: + self._schedule(self.connection.on_speaking(data)) + + case VoiceOpType.heartbeat_ack: + self._heartbeat_ack_pending = False + if self._last_send: + self._latencies.append(time.perf_counter() - self._last_send) + + case VoiceOpType.resumed: + self._schedule(self.connection.on_resumed(data)) + + case VoiceOpType.client_disconnect: + self._schedule(self.connection.on_client_disconnect(data)) + + case ( + VoiceOpType.dave_prepare_transition + | VoiceOpType.dave_execute_transition + | VoiceOpType.dave_prepare_epoch + ): + # The DAVE transition/epoch control ops arrive as JSON text frames + # (only the MLS data ops 25-31 are binary), so route them with the + # decoded payload. Handling these keeps the local MLS session in + # step with the gateway's epoch; ignoring them desyncs the epoch and + # leads to MLS WrongEpoch errors and a 4006 close on rejoin. + self._schedule(self.connection.on_dave_json(int(voice_op), data)) + + case _: + _log.debug(f"Voice socket for guild {self.connection.guild_id} received unhandled op {voice_op}") + + def _dispatch_binary(self, raw: bytes) -> None: + """ + Parse and dispatch a binary DAVE frame. + + Parameters + ---------- + raw: + The raw binary frame: ``seq(2B >H) + opcode(1B) + payload``. + """ + if len(raw) < 3: + return + + seq, opcode = struct.unpack_from(">HB", raw, 0) + payload = raw[3:] + + self.seq_ack = seq + + self._schedule(self.connection.on_dave_binary(opcode, payload)) + + def _schedule(self, coro: Coroutine[Any, Any, Any]) -> None: + """ + Schedule a coroutine as a task so the receive loop never blocks. + + Parameters + ---------- + coro: + The coroutine to run independently of the receive loop. + """ + task = asyncio.create_task( + self._guard(coro), + name=f"discord.http/voice/socket-{self.connection.guild_id}/dispatch" + ) + self._dispatch_tasks.add(task) + task.add_done_callback(self._dispatch_tasks.discard) + + async def _guard(self, coro: Coroutine[Any, Any, Any]) -> None: + """ + Run a scheduled coroutine, logging any exception it raises. + + Parameters + ---------- + coro: + The coroutine to await. + """ + try: + await coro + except Exception as exc: + _log.error(f"Error in voice socket handler for guild {self.connection.guild_id}", exc_info=exc) + + async def _handle_hello(self) -> None: + """ React to HELLO (op 8): authenticate, then start heartbeating. """ + # IDENTIFY/RESUME MUST be the first payload sent on the voice gateway. + # The heartbeat loop emits a heartbeat (op 3) immediately, so it can only + # be started *after* authentication has been sent; otherwise Discord sees + # a payload before IDENTIFY and closes the socket with code 4003 + # ("Not authenticated"). + if self._resuming: + await self.send_resume() + else: + await self.send_identify() + + self._start_heartbeat() + + def _start_heartbeat(self) -> None: + """ (Re)start the heartbeat task using the negotiated interval. """ + if self._heartbeat_task is not None and not self._heartbeat_task.done(): + self._heartbeat_task.cancel() + + self._heartbeat_ack_pending = False + self._heartbeat_task = asyncio.create_task( + self._heartbeat_loop(), + name=f"discord.http/voice/socket-{self.connection.guild_id}/heartbeat" + ) + + async def _heartbeat_loop(self) -> None: + """ Send heartbeats until cancelled or the gateway stops acknowledging them. """ + try: + while True: + # Sleep *before* the first beat: the voice gateway must complete + # the IDENTIFY -> READY -> SESSION_DESCRIPTION handshake without + # any heartbeat interleaved. Sending op 3 before READY makes + # Discord invalidate the session (close code 4006). This mirrors + # discord.py's voice keep-alive, which also waits one interval. + await asyncio.sleep(self._heartbeat_interval) + + # Only one heartbeat may be outstanding. If the previous one was + # not acknowledged within a full interval, the websocket can be + # half-open even though no close frame arrived. Hand ownership to + # the connection's existing reconnect path rather than silently + # leaving a dead heartbeat task behind. + if self._heartbeat_ack_pending: + _log.debug(f"Voice heartbeat for guild {self.connection.guild_id} was not acknowledged; reconnecting") + self.connection._on_socket_closed(None) + return + + await self._send_heartbeat() + except asyncio.CancelledError: + pass + except Exception as exc: + _log.debug(f"Voice heartbeat for guild {self.connection.guild_id} stopped", exc_info=exc) + if not self._closing: + self.connection._on_socket_closed(None) + + async def _send_heartbeat(self) -> None: + """ Send a single heartbeat frame, recording the send time for latency. """ + self._last_send = time.perf_counter() + self._heartbeat_ack_pending = True + nonce = int(time.time() * 1000) + await self._send_json({ + "op": int(VoiceOpType.heartbeat), + "d": { + "t": nonce, + "seq_ack": self.seq_ack, + } + }) + + async def _send_json(self, payload: dict) -> None: + """ + Send a JSON frame over the websocket. + + Parameters + ---------- + payload: + The payload to serialise and send. + """ + if self.ws is None or self.ws.closed: + return + # JSON control frames MUST be sent as text frames: the voice gateway + # reserves binary frames for DAVE/E2EE opcodes (see ``send_binary`` and + # ``_dispatch_binary``). Sending JSON via ``send_bytes`` makes Discord + # treat IDENTIFY as a malformed DAVE frame, so it never replies with + # READY/SESSION_DESCRIPTION and the handshake times out. + await self.ws.send_str(orjson.dumps(payload).decode("utf-8")) + + async def send_identify(self) -> None: + """ Send the IDENTIFY (op 0) frame, advertising DAVE support. """ + from .dave import max_protocol_version + + await self._send_json({ + "op": int(VoiceOpType.identify), + "d": { + "server_id": str(self.connection.guild_id), + "user_id": str(self.connection.user_id), + "session_id": self.connection.session_id, + "token": self.connection.token, + "max_dave_protocol_version": max_protocol_version(), + } + }) + + async def send_select_protocol(self, ip: str, port: int, mode: str) -> None: + """ + Send the SELECT_PROTOCOL (op 1) frame after IP discovery. + + Parameters + ---------- + ip: + The externally discovered IP address. + port: + The externally discovered UDP port. + mode: + The negotiated encryption mode. + """ + await self._send_json({ + "op": int(VoiceOpType.select_protocol), + "d": { + "protocol": "udp", + "data": { + "address": ip, + "port": port, + "mode": mode, + } + } + }) + + async def send_speaking(self, speaking: int, *, ssrc: int, delay: int = 0) -> None: + """ + Send the SPEAKING (op 5) frame. + + Parameters + ---------- + speaking: + The speaking bitflag (1 to indicate microphone audio). + ssrc: + The SSRC of the connection. + delay: + The voice delay, in milliseconds. + """ + await self._send_json({ + "op": int(VoiceOpType.speaking), + "d": { + "speaking": int(speaking), + "delay": int(delay), + "ssrc": int(ssrc), + } + }) + + async def send_resume(self) -> None: + """ Send the RESUME (op 7) frame to resume an interrupted session. """ + await self._send_json({ + "op": int(VoiceOpType.resume), + "d": { + "server_id": str(self.connection.guild_id), + "session_id": self.connection.session_id, + "token": self.connection.token, + "seq_ack": self.seq_ack, + } + }) + + async def send_transition_ready(self, transition_id: int) -> None: + """ + Send the DAVE TRANSITION_READY (op 23) acknowledgement. + + This is a JSON control frame (not a binary DAVE frame): it carries the + ``transition_id`` as JSON, matching the voice gateway protocol. + + Parameters + ---------- + transition_id: + The id of the transition being acknowledged. + """ + await self._send_json({ + "op": int(VoiceOpType.dave_transition_ready), + "d": { + "transition_id": transition_id, + } + }) + + async def send_binary(self, opcode: int, payload: bytes) -> None: + """ + Send a binary DAVE frame. + + Outbound binary frames are framed as ``opcode(1B) + payload`` with NO + sequence prefix. This is asymmetric with *inbound* binary frames, which + Discord prefixes with a 2-byte sequence number (``seq(2B) + opcode(1B) + + payload``, handled in :meth:`_dispatch_binary`). Prefixing outbound + frames with the 2-byte sequence makes Discord read the leading ``0x00`` + byte as opcode 0 (IDENTIFY) and close the socket with 4005 + ("Already authenticated"). + + Parameters + ---------- + opcode: + The voice opcode for the binary frame. + payload: + The binary payload to send after the opcode. + """ + if self.ws is None or self.ws.closed: + return + + frame = bytes([opcode & 0xFF]) + payload + await self.ws.send_bytes(frame) + + async def close(self) -> None: + """ Cancel the background tasks and close the websocket. """ + self._closing = True + + if self._heartbeat_task is not None: + self._heartbeat_task.cancel() + self._heartbeat_task = None + + if self._receive_task is not None: + self._receive_task.cancel() + self._receive_task = None + + # close() can be awaited *from* a dispatch task (e.g. a handler that + # ends up driving a disconnect), so never cancel the current task: + # doing so would raise CancelledError inside this very call. The tasks + # discard themselves from the set via their done-callback. + current = asyncio.current_task() + for task in list(self._dispatch_tasks): + if task is not current: + task.cancel() + + if self.ws is not None and not self.ws.closed: + await self.ws.close() + self.ws = None diff --git a/docs/api/voice.rst b/docs/api/voice.rst index 0e313de..4db135e 100644 --- a/docs/api/voice.rst +++ b/docs/api/voice.rst @@ -1,10 +1,66 @@ Voice ===== -discord\_http.voice module --------------------------- +discord\_http.voice.client module +--------------------------------- -.. automodule:: discord_http.voice +.. automodule:: discord_http.voice.client + :members: + :undoc-members: + :show-inheritance: + +discord\_http.voice.connection module +------------------------------------- + +.. automodule:: discord_http.voice.connection + :members: + :undoc-members: + :show-inheritance: + +discord\_http.voice.player module +--------------------------------- + +.. automodule:: discord_http.voice.player + :members: + :undoc-members: + :show-inheritance: + +discord\_http.voice.receiver module +----------------------------------- + +.. automodule:: discord_http.voice.receiver + :members: + :undoc-members: + :show-inheritance: + +discord\_http.voice.sinks module +-------------------------------- + +.. automodule:: discord_http.voice.sinks + :members: + :undoc-members: + :show-inheritance: + +discord\_http.voice.dave module +------------------------------- + +.. automodule:: discord_http.voice.dave + :members: + :undoc-members: + :show-inheritance: + +discord\_http.voice.opus module +------------------------------- + +.. automodule:: discord_http.voice.opus + :members: + :undoc-members: + :show-inheritance: + +discord\_http.voice.enums module +-------------------------------- + +.. automodule:: discord_http.voice.enums :members: :undoc-members: :show-inheritance: diff --git a/examples/voice_example.py b/examples/voice_example.py new file mode 100644 index 0000000..f53bc48 --- /dev/null +++ b/examples/voice_example.py @@ -0,0 +1,135 @@ +import asyncio + +from discord_http import BaseChannel, Client, Context, PartialChannel, VoiceClient, WaveSink +from discord_http.gateway import GatewayCacheFlags, Intents + +# Voice requires a gateway connection (to send the voice-state update) and the +# guild_voice_states intent (so the bot receives its own voice server/state updates). +# +# To follow whoever ran a command, the bot must *cache* voice states, which needs: +# * Intents.guilds -> so GUILD_CREATE fires and guilds get cached (voice +# states are stored on the guild; without it ctx.guild +# is an empty stub and nothing is ever kept) +# * Intents.guild_voice_states -> so Discord actually SENDS voice state updates +# * GatewayCacheFlags.guilds | .voice_states -> so the library keeps both +# Miss any of these and get_member_voice_state() stays empty -> the bot thinks nobody +# is in a channel. +# +# Codec notes: +# * Passing an ``.mp3``/``.opus`` file plays through ffmpeg -> Ogg/Opus and needs +# ONLY ffmpeg installed (no libopus) -- the audio is sent as opus passthrough. +# * PCM encode/decode (raw PCM sources, volume transforms, or receiving/decoding +# other users' audio) additionally needs libopus loaded (``discord_http.voice.load_opus``). +# * DAVE end-to-end encryption (MLS) is optional and needs: pip install "discord.http[voice]" +client = Client( + token="BOT_TOKEN", + enable_gateway=True, + intents=( + Intents.guilds | + Intents.guild_messages | + Intents.guild_voice_states + ), + gateway_cache=( + GatewayCacheFlags.guilds | + GatewayCacheFlags.voice_states + ) +) + + +def caller_voice_channel(ctx: Context) -> "BaseChannel | PartialChannel | None": + """ + Resolve the voice channel the invoking member is currently sitting in. + + This reads the member's cached voice state (populated from the gateway via the + guild_voice_states intent) instead of relying on a hard-coded channel id, so the + bot always follows whoever ran the command. + """ + if ctx.guild is None or ctx.user is None: + return None + + voice_state = ctx.guild.get_member_voice_state(ctx.user.id) + if voice_state is None: + return None + + # ``channel`` is available on both ``VoiceState`` and ``PartialVoiceState`` and + # always returns something ``connect()`` can act on (a partial channel when the + # full object is not cached), or ``None`` when the user is not in a channel. + return voice_state.channel + + +@client.command() +async def join(ctx: Context): + """ Join the caller's voice channel and play a song """ + channel = caller_voice_channel(ctx) + if channel is None: + return ctx.response.send_message("Join a voice channel first, then try again.") + + vc: VoiceClient = await channel.connect() + + # Play a local file (mp3 -> opus passthrough, ffmpeg only). + vc.play("song.mp3") + + return ctx.response.send_message(f"Now playing, latency: {vc.latency * 1000:.1f}ms") + + +@client.command() +async def pause(ctx: Context): + """ Pause / resume the current track """ + vc = client.get_voice_client(ctx.guild.id) if ctx.guild else None + if vc is None: + return ctx.response.send_message("Not connected.") + + if vc.is_paused(): + vc.resume() + return ctx.response.send_message("Resumed.") + + vc.pause() + return ctx.response.send_message("Paused.") + + +@client.command() +async def leave(ctx: Context): + """ Stop playback and disconnect """ + vc = client.get_voice_client(ctx.guild.id) if ctx.guild else None + if vc is None: + return ctx.response.send_message("Not connected.") + + vc.stop() + await vc.disconnect() + return ctx.response.send_message("Disconnected.") + + +async def voice_demo(channel: BaseChannel, move_to: BaseChannel) -> None: + """ + A standalone walkthrough of the voice API. + + Parameters + ---------- + channel: + The voice channel to connect to first. + move_to: + A second voice channel to move into mid-session. + """ + vc: VoiceClient = await channel.connect() + + # Playback controls. + vc.play("song.mp3") + vc.pause() + vc.resume() + + # Hop to another channel without disconnecting. + await vc.move_to(move_to) + + # Receiving: write everyone's audio into a single WAV file. + # (decoding opus -> PCM for the WAV needs libopus loaded.) + vc.listen(WaveSink("out.wav")) + await asyncio.sleep(10) + vc.stop_listening() + + print(f"voice latency: {vc.latency * 1000:.1f}ms (avg {vc.average_latency * 1000:.1f}ms)") + + vc.stop() + await vc.disconnect() + + +client.start(host="127.0.0.1", port=8080) diff --git a/pyproject.toml b/pyproject.toml index b8ec701..a59bda1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,11 +45,15 @@ docs = [ "sphinx>=8.2.3", "sphinx-autodoc-typehints>=3.2.0", ] +voice = [ + "davey>=0.1.0", +] [tool.setuptools] packages = [ "discord_http", "discord_http.gateway", + "discord_http.voice", ] [tool.setuptools.dynamic] @@ -66,6 +70,7 @@ output-format = "concise" include = [ "discord_http/*.py", "discord_http/gateway/*.py", + "discord_http/voice/*.py", ] exclude = [ @@ -158,6 +163,9 @@ ignore = [ # Variable shadowing "A005", # Files + + # Ruff-specific + "RUF105", # Preview, stylistic: wants `ruff:ignore` comments instead of `noqa` ] [tool.ruff.lint.isort] @@ -186,6 +194,7 @@ pythonVersion = "3.11" include = [ "discord_http", "discord_http.gateway", + "discord_http.voice", ] exclude = [ diff --git a/tests/test_voice_connection.py b/tests/test_voice_connection.py new file mode 100644 index 0000000..967d6e6 --- /dev/null +++ b/tests/test_voice_connection.py @@ -0,0 +1,159 @@ +import asyncio +import unittest + +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +from discord_http.voice.connection import VoiceConnection + +class TestVoiceServerMigration(unittest.IsolatedAsyncioTestCase): + def _connection(self) -> VoiceConnection: + connection = object.__new__(VoiceConnection) + connection.guild_id = 1 + connection.token = "old-token" + connection.endpoint = "old.discord.media:443" + connection.server_id = 1 + connection._server_event = asyncio.Event() + connection._connected_event = asyncio.Event() + connection._connected_event.set() + connection._closing = False + connection._reconnect_task = None + connection._move_target_channel_id = None + connection._move_server_update_received = False + return connection + + async def test_changed_server_credentials_schedule_migration(self) -> None: + """ Schedule migration after connected credentials change. """ + connection = self._connection() + connection._migrate_voice_server = AsyncMock() # type: ignore[method-assign] + + connection.on_voice_server_update( + { + "token": "new-token", + "endpoint": "new.discord.media:2053", + "guild_id": "1", + } + ) + task = connection._reconnect_task + self.assertIsNotNone(task) + await task + + self.assertEqual(connection.token, "new-token") + self.assertEqual(connection.endpoint, "new.discord.media:2053") + connection._migrate_voice_server.assert_awaited_once() + + async def test_unchanged_server_credentials_do_not_migrate(self) -> None: + """ Leave the active connection alone when credentials are unchanged. """ + connection = self._connection() + connection._migrate_voice_server = AsyncMock() # type: ignore[method-assign] + + connection.on_voice_server_update( + { + "token": "old-token", + "endpoint": "old.discord.media:443", + "guild_id": "1", + } + ) + + self.assertIsNone(connection._reconnect_task) + connection._migrate_voice_server.assert_not_awaited() + + async def test_move_waits_for_state_update_when_server_update_arrives_first(self) -> None: + """ Pair move updates before identifying a replacement voice session. """ + connection = self._connection() + shard = SimpleNamespace(change_voice_state=AsyncMock()) + receiver = SimpleNamespace(reset=Mock()) + connection.voice_client = SimpleNamespace(_receiver=receiver) + connection._get_shard = Mock(return_value=shard) # type: ignore[method-assign] + connection._migrate_voice_server = AsyncMock() # type: ignore[method-assign] + connection.channel_id = 123 + connection.session_id = "old-session" + connection._left_event = asyncio.Event() + connection._state_event = asyncio.Event() + + await connection.move_to(SimpleNamespace(id=456)) # type: ignore[arg-type] + connection.on_voice_server_update({ + "token": "new-token", + "endpoint": "old.discord.media:443", + "guild_id": "1", + }) + + self.assertIsNone(connection._reconnect_task) + self.assertTrue(connection._move_server_update_received) + + connection.on_voice_state_update({"session_id": "new-session", "channel_id": "456"}) + task = connection._reconnect_task + self.assertIsNotNone(task) + await task + + connection._migrate_voice_server.assert_awaited_once() + self.assertEqual(connection.session_id, "new-session") + + async def test_move_waits_for_server_update_when_state_update_arrives_first(self) -> None: + """ Do not reuse old credentials after the gateway acknowledges a move first. """ + connection = self._connection() + shard = SimpleNamespace(change_voice_state=AsyncMock()) + receiver = SimpleNamespace(reset=Mock()) + connection.voice_client = SimpleNamespace(_receiver=receiver) + connection._get_shard = Mock(return_value=shard) # type: ignore[method-assign] + connection._migrate_voice_server = AsyncMock() # type: ignore[method-assign] + connection.channel_id = 123 + connection.session_id = "old-session" + connection._left_event = asyncio.Event() + connection._state_event = asyncio.Event() + + await connection.move_to(SimpleNamespace(id=456)) # type: ignore[arg-type] + connection.on_voice_state_update({"session_id": "new-session", "channel_id": "456"}) + + self.assertIsNone(connection._reconnect_task) + + connection.on_voice_server_update({ + "token": "new-token", + "endpoint": "old.discord.media:443", + "guild_id": "1", + }) + task = connection._reconnect_task + self.assertIsNotNone(task) + await task + + connection._migrate_voice_server.assert_awaited_once() + receiver.reset.assert_called_once_with() + + async def test_failed_soft_migration_forces_voice_refresh(self) -> None: + """ Refresh gateway voice state when direct migration fails. """ + connection = self._connection() + connection._soft_reconnect = AsyncMock(return_value=False) # type: ignore[method-assign] + connection._full_reconnect = AsyncMock() # type: ignore[method-assign] + + await connection._migrate_voice_server() + + connection._soft_reconnect.assert_awaited_once() + connection._full_reconnect.assert_awaited_once_with(None, force_refresh=True) + + async def test_move_clears_ssrc_state_after_gateway_update(self) -> None: + """ Reset SSRC state only after the gateway acknowledges a move. """ + connection = self._connection() + shard = SimpleNamespace(change_voice_state=AsyncMock()) + receiver = SimpleNamespace(reset=Mock()) + connection.voice_client = SimpleNamespace(_receiver=receiver) + connection._get_shard = Mock(return_value=shard) # type: ignore[method-assign] + connection.channel_id = 123 + connection.session_id = "session" + connection._left_event = asyncio.Event() + connection._state_event = asyncio.Event() + channel = SimpleNamespace(id=456) + + await connection.move_to(channel) # type: ignore[arg-type] + + shard.change_voice_state.assert_awaited_once_with(guild_id=1, channel_id=456) + self.assertEqual(connection.channel_id, 123) + receiver.reset.assert_not_called() + + connection.on_voice_state_update({"session_id": "session", "channel_id": "456"}) + + self.assertEqual(connection.channel_id, 456) + receiver.reset.assert_called_once_with() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_voice_dave.py b/tests/test_voice_dave.py new file mode 100644 index 0000000..9e1f2b5 --- /dev/null +++ b/tests/test_voice_dave.py @@ -0,0 +1,155 @@ +import asyncio +import unittest + +from unittest.mock import AsyncMock, patch + +from discord_http.voice.dave import DaveManager + +class _FakeSocket: + """Records the transition ids acknowledged with TRANSITION_READY.""" + + def __init__(self) -> None: + self.ready_ids: list[int] = [] + + async def send_transition_ready(self, transition_id: int) -> None: + self.ready_ids.append(transition_id) + + +class _FakeSession: + """A stand-in for davey.DaveSession that reports itself ready.""" + + def __init__(self) -> None: + self.ready = True + self.passthrough: bool | None = None + + def set_passthrough_mode(self, passthrough_mode: bool) -> None: + self.passthrough = passthrough_mode + + +class _FakeConnection: + """The minimal connection surface DaveManager's transition handling touches.""" + + def __init__(self, channel_id: int | None = 1234) -> None: + self.socket = _FakeSocket() + self.channel_id = channel_id + self.user_id = 5678 + self.dave_protocol_version = 0 + + +def _manager(channel_id: int | None = 1234) -> tuple[DaveManager, _FakeSocket]: + connection = _FakeConnection(channel_id) + manager = DaveManager(connection) # type: ignore[arg-type] + return manager, connection.socket + + +class TestDaveTransitions(unittest.TestCase): + def test_prepare_records_pending_and_acks(self) -> None: + manager, socket = _manager() + + asyncio.run(manager._handle_prepare_transition({"transition_id": 5, "protocol_version": 1})) + + self.assertEqual(manager._pending_transitions, {5: 1}) + self.assertEqual(socket.ready_ids, [5]) + + def test_execute_applies_pending_version_and_pops(self) -> None: + manager, _ = _manager() + + asyncio.run(manager._handle_prepare_transition({"transition_id": 5, "protocol_version": 1})) + asyncio.run(manager._handle_execute_transition({"transition_id": 5})) + + self.assertEqual(manager._version, 1) + self.assertEqual(manager._pending_transitions, {}) + + def test_execute_unknown_transition_is_ignored(self) -> None: + manager, _ = _manager() + + asyncio.run(manager._handle_execute_transition({"transition_id": 9})) + + self.assertEqual(manager._version, 0) + self.assertEqual(manager._pending_transitions, {}) + + def test_overlapping_transitions_do_not_clobber(self) -> None: + # Two transitions pending at once (e.g. member churn) must each keep + # their own target version until their EXECUTE_TRANSITION arrives. + manager, socket = _manager() + + asyncio.run(manager._handle_prepare_transition({"transition_id": 1, "protocol_version": 1})) + asyncio.run(manager._handle_prepare_transition({"transition_id": 2, "protocol_version": 0})) + self.assertEqual(manager._pending_transitions, {1: 1, 2: 0}) + self.assertEqual(socket.ready_ids, [1, 2]) + + asyncio.run(manager._handle_execute_transition({"transition_id": 1})) + self.assertEqual(manager._version, 1) + self.assertEqual(manager._pending_transitions, {2: 0}) + + asyncio.run(manager._handle_execute_transition({"transition_id": 2})) + self.assertEqual(manager._version, 0) + self.assertEqual(manager._pending_transitions, {}) + + def test_transition_id_zero_executes_immediately_without_ack(self) -> None: + manager, socket = _manager() + + asyncio.run(manager._handle_prepare_transition({"transition_id": 0, "protocol_version": 0})) + + self.assertEqual(manager._pending_transitions, {}) + self.assertEqual(socket.ready_ids, []) + + def test_downgrade_to_version_zero_disables_encryption(self) -> None: + # A live, ready session is not enough: after transitioning down to + # protocol version 0 nothing is encrypted any more, and can_encrypt() + # must say so or the receiver drops plain Opus packets. + manager, _ = _manager() + manager._session = _FakeSession() # type: ignore[assignment] + manager._version = 1 + self.assertTrue(manager.can_encrypt()) + + asyncio.run(manager._handle_prepare_transition({"transition_id": 3, "protocol_version": 0})) + asyncio.run(manager._handle_execute_transition({"transition_id": 3})) + + self.assertEqual(manager._version, 0) + self.assertTrue(manager.ready) + self.assertFalse(manager.can_encrypt()) + + def test_prepare_epoch_one_reinitializes_new_group(self) -> None: + """ Reinitialize DAVE when epoch one announces a new MLS group. """ + manager, _ = _manager() + + with patch.object(DaveManager, "reinit", new_callable=AsyncMock) as reinit: + asyncio.run(manager._handle_prepare_epoch({"epoch": 1, "protocol_version": 1})) + + self.assertEqual(manager._connection.dave_protocol_version, 1) + reinit.assert_awaited_once_with(1) + + def test_later_prepare_epoch_keeps_current_group(self) -> None: + """ Keep the current DAVE session for later epochs in the same MLS group. """ + manager, _ = _manager() + + with patch.object(DaveManager, "reinit", new_callable=AsyncMock) as reinit: + asyncio.run(manager._handle_prepare_epoch({"epoch": 2, "protocol_version": 1})) + + self.assertEqual(manager._connection.dave_protocol_version, 0) + reinit.assert_not_awaited() + + def test_reinit_without_channel_resets_version(self) -> None: + # No channel means no session; the version must not stay non-zero, or + # encrypt_opus would pass plaintext into a channel Discord treats as E2EE. + manager, _ = _manager(channel_id=None) + + asyncio.run(manager.reinit(1)) + + self.assertIsNone(manager._session) + self.assertEqual(manager._version, 0) + self.assertFalse(manager.can_encrypt()) + + def test_reinit_clears_pending_transitions(self) -> None: + manager, _ = _manager() + + asyncio.run(manager._handle_prepare_transition({"transition_id": 5, "protocol_version": 1})) + asyncio.run(manager.reinit(0)) + + self.assertEqual(manager._pending_transitions, {}) + self.assertIsNone(manager._session) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_voice_encryptor.py b/tests/test_voice_encryptor.py new file mode 100644 index 0000000..9453821 --- /dev/null +++ b/tests/test_voice_encryptor.py @@ -0,0 +1,68 @@ +import os +import struct +import unittest + +from discord_http.voice.encryptor import Encryptor + + +class TestVoiceEncryptor(unittest.TestCase): + def test_roundtrip_basic_header(self) -> None: + key = os.urandom(32) + header = struct.pack(">BBHII", 0x80, 0x78, 1, 2, 3) + plaintext = b"opus-frame-data" + + sender = Encryptor(key) + packet = sender.encrypt(header, plaintext) + + self.assertEqual(packet[:12], header) + self.assertEqual(packet[-4:], struct.pack(">I", 0)) + + receiver = Encryptor(key) + self.assertEqual(receiver.decrypt(packet), plaintext) + + def test_roundtrip_with_extension(self) -> None: + key = os.urandom(32) + + # base header with the extension bit (0x10) set on byte0 + base = struct.pack(">BBHII", 0x90, 0x78, 5, 6, 7) + # one-byte RTP extension: 0xBE 0xDE profile, length = 1 word (4 bytes) + extension = b"\xbe\xde" + struct.pack(">H", 1) + b"\x01\x02\x03\x04" + header = base + extension + plaintext = b"another-opus-frame" + + sender = Encryptor(key) + packet = sender.encrypt(header, plaintext) + + self.assertEqual(packet[:len(header)], header) + + receiver = Encryptor(key) + self.assertEqual(receiver.decrypt(packet), plaintext) + + def test_nonce_increments(self) -> None: + key = os.urandom(32) + header = struct.pack(">BBHII", 0x80, 0x78, 1, 2, 3) + + sender = Encryptor(key) + first = sender.encrypt(header, b"a") + second = sender.encrypt(header, b"a") + + self.assertEqual(first[-4:], struct.pack(">I", 0)) + self.assertEqual(second[-4:], struct.pack(">I", 1)) + + def test_nonce_exhaustion_raises(self) -> None: + # Reusing a (key, nonce) pair with AES-GCM is catastrophic, so the + # counter must refuse to wrap back to 0 instead of silently reusing. + key = os.urandom(32) + header = struct.pack(">BBHII", 0x80, 0x78, 1, 2, 3) + + sender = Encryptor(key) + sender._nonce = 2 ** 32 - 1 + packet = sender.encrypt(header, b"a") + self.assertEqual(packet[-4:], struct.pack(">I", 2 ** 32 - 1)) + + with self.assertRaises(RuntimeError): + sender.encrypt(header, b"a") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_voice_oggparse.py b/tests/test_voice_oggparse.py new file mode 100644 index 0000000..0a96296 --- /dev/null +++ b/tests/test_voice_oggparse.py @@ -0,0 +1,128 @@ +import asyncio +import io +import shutil +import struct +import unittest + +from discord_http.voice.oggparse import OggPage + + +def _build_page( + body: bytes, + segtable: bytes, + *, + header_type: int = 0, + granule_position: int = 0, + serial: int = 1, + sequence: int = 0, + crc: int = 0, +) -> bytes: + """Build a single valid Ogg page from a body and a hand-crafted segment table.""" + if sum(segtable) != len(body): + raise ValueError("segment table must sum to body length") + header = struct.pack( + "<4sBBQIIIB", + b"OggS", + 0, # version + header_type, + granule_position, + serial, + sequence, + crc, + len(segtable), + ) + return header + segtable + body + + +def _parse_page(page_bytes: bytes) -> OggPage: + """Parse a single page built by ``_build_page``, skipping the 4-byte magic.""" + buffer = io.BytesIO(page_bytes) + assert buffer.read(4) == b"OggS" + return OggPage(buffer) + + +class TestOggParse(unittest.TestCase): + def test_page_header_fields_parsed(self) -> None: + body = b"\x00\x01\x02\x03" + page = _parse_page(_build_page( + body, + bytes([len(body)]), + header_type=0x02, + granule_position=12345, + sequence=7, + )) + + self.assertEqual(page.header_type, 0x02) + self.assertEqual(page.granule_position, 12345) + self.assertEqual(page.page_sequence_number, 7) + self.assertEqual(page.segtable, bytes([len(body)])) + self.assertEqual(page.data, body) + + def test_multiple_packets_in_one_page(self) -> None: + packet_a = b"first" + packet_b = b"second-packet" + body = packet_a + packet_b + segtable = bytes([len(packet_a), len(packet_b)]) + + page = _parse_page(_build_page(body, segtable)) + self.assertEqual( + list(page.iter_packets()), + [(packet_a, True), (packet_b, True)], + ) + + def test_packet_spanning_segments_via_255_lacing(self) -> None: + # A packet exactly 255 bytes long needs a 255 lacing + a 0 lacing terminator. + body = b"x" * 255 + segtable = bytes([255, 0]) + + page = _parse_page(_build_page(body, segtable)) + self.assertEqual(list(page.iter_packets()), [(body, True)]) + + def test_packet_spanning_pages(self) -> None: + # First page ends mid-packet (trailing 255 lacing), second page continues it. + head = b"a" * 255 + tail = b"bcd" + page_one = _parse_page(_build_page(head, bytes([255]), sequence=0)) + page_two = _parse_page(_build_page(tail, bytes([len(tail)]), header_type=0x01, sequence=1)) + + self.assertEqual(list(page_one.iter_packets()), [(head, False)]) + self.assertEqual(list(page_two.iter_packets()), [(tail, True)]) + + def test_truncated_page_raises(self) -> None: + body = b"payload" + page_bytes = _build_page(body, bytes([len(body)])) + + # Chop off the last body byte; parsing must fail rather than mis-parse. + with self.assertRaises(ValueError): + _parse_page(page_bytes[:-1]) + + def test_ffmpeg_generated_opus_stream(self) -> None: + if shutil.which("ffmpeg") is None: + self.skipTest("ffmpeg not available on PATH") + + from discord_http.voice.player import FFmpegOpusAudio + + async def read_all() -> list[bytes]: + source = FFmpegOpusAudio( + "sine=frequency=440:duration=1", + before_options="-f lavfi", + ) + packets = [] + try: + while packet := await source.read(): + packets.append(packet) + finally: + source.cleanup() + return packets + + packets = asyncio.run(read_all()) + + # The OpusHead/OpusTags header packets are filtered out by the source, + # so everything left must be audio packets. + self.assertGreater(len(packets), 0) + for packet in packets: + self.assertFalse(packet.startswith((b"OpusHead", b"OpusTags"))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_voice_receiver.py b/tests/test_voice_receiver.py new file mode 100644 index 0000000..57db278 --- /dev/null +++ b/tests/test_voice_receiver.py @@ -0,0 +1,67 @@ +import unittest + +from discord_http.voice.receiver import VoiceReceiver +from discord_http.voice.sinks import AudioSink, VoiceData + +class _Sink(AudioSink): + def __init__(self) -> None: + self.cleaned = False + + def write(self, user: int | None, data: VoiceData) -> None: + pass + + def cleanup(self) -> None: + self.cleaned = True + + +class _Decoder: + def __init__(self) -> None: + self.cleaned = False + + def cleanup(self) -> None: + self.cleaned = True + + +class TestVoiceReceiverState(unittest.TestCase): + def _receiver(self) -> VoiceReceiver: + return VoiceReceiver(None) # type: ignore[arg-type] + + def test_stop_keeps_ssrc_map(self) -> None: + """ Stop sink delivery without discarding eagerly collected SSRC mappings. """ + # SPEAKING is mapped before listen(), and start() calls stop() when a + # sink is swapped, so stop() must not wipe the mappings. + receiver = self._receiver() + receiver.add_ssrc(1, 100) + + receiver.start(_Sink()) + receiver.start(_Sink()) + receiver.stop() + + self.assertEqual(receiver._ssrc_map, {1: 100}) + + def test_reset_clears_ssrc_state_and_keeps_sink(self) -> None: + """ Reset SSRC-keyed state without finalizing the active sink. """ + # READY allocates fresh SSRCs but a reconnect must keep the active sink. + receiver = self._receiver() + sink = _Sink() + decoder = _Decoder() + receiver.start(sink) + receiver.add_ssrc(1, 100) + receiver._last_seq[1] = 7 + receiver._decoders[1] = decoder # type: ignore[assignment] + receiver._dave_unmapped_drops = 7 + + receiver.reset() + + self.assertEqual(receiver._ssrc_map, {}) + self.assertEqual(receiver._last_seq, {}) + self.assertEqual(receiver._decoders, {}) + self.assertEqual(receiver._dave_unmapped_drops, 0) + self.assertTrue(decoder.cleaned) + self.assertTrue(receiver.is_listening()) + self.assertIs(receiver.sink, sink) + self.assertFalse(sink.cleaned) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_voice_sinks.py b/tests/test_voice_sinks.py new file mode 100644 index 0000000..4346428 --- /dev/null +++ b/tests/test_voice_sinks.py @@ -0,0 +1,27 @@ +import tempfile +import unittest + +from pathlib import Path + +from discord_http.voice.sinks import VoiceData, WaveSink + +class TestWaveSink(unittest.TestCase): + def test_finalized_sink_cannot_truncate_existing_recording(self) -> None: + """ Refuse WaveSink reuse instead of silently truncating its recording. """ + with tempfile.TemporaryDirectory() as directory: + destination = Path(directory) / "recording.wav" + sink = WaveSink(destination) + data = VoiceData(user=1, pcm=b"\x00\x00\x00\x00", opus=None, timestamp=0, ssrc=1) + + sink.write(1, data) + sink.cleanup() + original = destination.read_bytes() + + with self.assertRaisesRegex(RuntimeError, "finalized"): + sink.write(1, data) + + self.assertEqual(destination.read_bytes(), original) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_voice_socket.py b/tests/test_voice_socket.py new file mode 100644 index 0000000..ff0a373 --- /dev/null +++ b/tests/test_voice_socket.py @@ -0,0 +1,49 @@ +import unittest + +from unittest.mock import AsyncMock + +import orjson + +from discord_http.voice.enums import VoiceOpType +from discord_http.voice.socket import VoiceSocket + +class _Connection: + guild_id = 1 + + def __init__(self) -> None: + self.closed: list[int | None] = [] + + def _on_socket_closed(self, close_code: int | None) -> None: + self.closed.append(close_code) + + +class TestVoiceHeartbeat(unittest.IsolatedAsyncioTestCase): + async def test_missing_ack_triggers_reconnect(self) -> None: + """ Trigger reconnect when a heartbeat remains unacknowledged for an interval. """ + connection = _Connection() + socket = VoiceSocket(connection) # type: ignore[arg-type] + socket._heartbeat_interval = 0 + socket._send_json = AsyncMock() # type: ignore[method-assign] + + await socket._heartbeat_loop() + + socket._send_json.assert_awaited_once() + self.assertEqual(connection.closed, [None]) + + async def test_ack_clears_pending_heartbeat(self) -> None: + """ Clear pending heartbeat state when the gateway acknowledges it. """ + connection = _Connection() + socket = VoiceSocket(connection) # type: ignore[arg-type] + socket._send_json = AsyncMock() # type: ignore[method-assign] + + await socket._send_heartbeat() + self.assertTrue(socket._heartbeat_ack_pending) + + socket._dispatch_text(orjson.dumps({"op": int(VoiceOpType.heartbeat_ack), "d": {}})) + + self.assertFalse(socket._heartbeat_ack_pending) + self.assertNotEqual(socket.latency, float("inf")) + + +if __name__ == "__main__": + unittest.main()