diff --git a/discord/calls.py b/discord/calls.py index 99a09a864..316f88974 100644 --- a/discord/calls.py +++ b/discord/calls.py @@ -24,21 +24,19 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations import datetime -from typing import Callable, Dict, List, Optional, TYPE_CHECKING, Union +from typing import Callable, Dict, List, Optional, Set, TYPE_CHECKING, Union from . import utils from .errors import ClientException -from .utils import MISSING +from .utils import cached_slot_property, MISSING if TYPE_CHECKING: - from .abc import Connectable, PrivateChannel, User as abcUser, T as ConnectReturn + from .abc import Connectable, PrivateChannel, User as abcUser, Snowflake as abcSnowflake, T as ConnectReturn from .channel import DMChannel, GroupChannel from .client import Client from .member import VoiceState from .message import Message from .state import ConnectionState - from .types.snowflake import Snowflake, SnowflakeList - from .types.voice import GuildVoiceState from .user import User _PrivateChannel = Union[DMChannel, GroupChannel] @@ -69,7 +67,7 @@ class CallMessage: Attributes ----------- ended_timestamp: Optional[:class:`datetime.datetime`] - A naive UTC datetime object that represents the time that the call has ended. + An aware UTC datetime object that represents the time that the call has ended. participants: List[:class:`User`] A list of users that participated in the call. message: :class:`Message` @@ -88,7 +86,7 @@ class CallMessage: @property def initiator(self) -> User: - """:class:`User`: Returns the user that started the call.""" + """:class:`.abc.User`: Returns the user that started the call.""" return self.message.author # type: ignore # Cannot be a Member in private messages @property @@ -119,16 +117,14 @@ class PrivateCall: This is accompanied with a :class:`CallMessage` denoting the information. + .. versionadded:: 1.9 + Attributes ----------- channel: :class:`DMChannel` The channel the call is in. - message: Optional[:class:`Message`] - The message associated with this call (if available). unavailable: :class:`bool` Denotes if this call is unavailable. - ringing: List[:class:`~discord.abc.User`] - A list of users that are currently being rung to join the call. region: :class:`str` The region the call is being hosted at. @@ -136,78 +132,89 @@ class PrivateCall: The type of this attribute has changed to :class:`str`. """ + __slots__ = ('_state', '_ended', 'channel', '_cs_message', '_ringing', '_message_id', 'region', 'unavailable') + if TYPE_CHECKING: channel: DMChannel - ringing: List[abcUser] - region: str def __init__( self, - state: ConnectionState, *, - message_id: Snowflake, - channel_id: Snowflake, - message: Optional[Message] = None, + data: dict, + state: ConnectionState, + message: Optional[Message], channel: PrivateChannel, - unavailable: bool, - voice_states: List[GuildVoiceState] = [], - **kwargs, ) -> None: self._state = state - self._message_id: int = int(message_id) - self._channel_id: int = int(channel_id) - self.message: Optional[Message] = message - self.channel = channel # type: ignore - self.unavailable: bool = unavailable + self._cs_message = message + self.channel = channel # type: ignore # Will always be a DMChannel here self._ended: bool = False - for vs in voice_states: - state._update_voice_state(vs, int(channel_id)) + self._update(data) - self._update(**kwargs) - - def _deleteup(self) -> None: - self.ringing = [] + def _delete(self) -> None: + self._ringing = tuple() self._ended = True + def _get_recipients(self) -> Set[abcUser]: + channel = self.channel + return {channel.me, channel.recipient} + def _is_participating(self, user: abcUser) -> bool: state = self.voice_state_for(user) - return bool(state and state.channel and state.channel.id == self._channel_id) + return bool(state and state.channel and state.channel.id == self.channel.id) + + def _update(self, data) -> None: + self._message_id = int(data['message_id']) + self.unavailable = data.get('unavailable', False) + try: + self.region: str = data['region'] + except KeyError: + pass - def _update(self, *, ringing: SnowflakeList = [], region: str = MISSING) -> None: - if region is not MISSING: - self.region = region channel = self.channel - recipients = {channel.me, channel.recipient} + recipients = self._get_recipients() lookup = {u.id: u for u in recipients} - self.ringing = list(filter(None, map(lookup.get, ringing))) + self._ringing = tuple(filter(None, map(lookup.get, data.get('ringing', [])))) + + for vs in data.get('voice_states', []): + self._state._update_voice_state(vs, channel.id) + + @property + def ringing(self) -> List[abcUser]: + """List[:class:`.abc.User`]: A list of users that are currently being rung to join the call.""" + return list(self._ringing) @property - def initiator(self) -> Optional[User]: - """Optional[:class:`User`]: Returns the user that started the call. The call message must be available to obtain this information.""" - if self.message: - return self.message.author # type: ignore # Cannot be a Member in private messages + def initiator(self) -> User: + """:class:`User`: Returns the user that started the call.""" + return getattr(self.message, 'author', None) @property def connected(self) -> bool: - """:class:`bool`: Returns whether you're in the call (this does not mean you're in the call through the lib).""" + """:class:`bool`: Returns whether you're in the call (this does not mean you're in the call through the library).""" return self._is_participating(self.channel.me) @property - def members(self) -> List[User]: - """List[:class:`User`]: Returns all users that are currently in this call.""" - channel = self.channel - recipients = {channel.me, channel.recipient} + def members(self) -> List[abcUser]: + """List[:class:`.abc.User`]: Returns all users that are currently in this call.""" + recipients = self._get_recipients() return [u for u in recipients if self._is_participating(u)] @property def voice_states(self) -> Dict[int, VoiceState]: """Mapping[:class:`int`, :class:`VoiceState`]: Returns a mapping of user IDs who have voice states in this call.""" return { - k: v for k, v in self._state._voice_states.items() if bool(v and v.channel and v.channel.id == self._channel_id) + k: v for k, v in self._state._voice_states.items() if bool(v and v.channel and v.channel.id == self.channel.id) } - async def fetch_message(self) -> Optional[Message]: + @cached_slot_property('_cs_message') + def message(self) -> Message: + """:class:`Message`: The message associated with this call.""" + # Lying to the type checker for better developer UX, very unlikely for the message to not be received + return self._state._get_message(self._message_id) # type: ignore + + async def fetch_message(self) -> Message: """|coro| Fetches and caches the message associated with this call. @@ -219,15 +226,15 @@ class PrivateCall: Returns ------- - Optional[:class:`Message`] + :class:`Message` The message associated with this call. """ message = await self.channel.fetch_message(self._message_id) state = self._state - if message is not None and self.message is None: - if self._state._messages is not None: - self._state._messages.append(message) - self.message = message + if self.message is None: + if state._messages is not None: + state._messages.append(message) + self._cs_message = message return message async def change_region(self, region: str) -> None: @@ -248,7 +255,7 @@ class PrivateCall: HTTPException Failed to change the channel's voice region. """ - await self._state.http.change_call_voice_region(self._channel_id, str(region)) + await self._state.http.change_call_voice_region(self.channel.id, region) @_running_only async def ring(self) -> None: @@ -258,6 +265,8 @@ class PrivateCall: Raises ------- + Forbidden + Not allowed to ring the other recipient. HTTPException Ringing failed. ClientException @@ -289,7 +298,6 @@ class PrivateCall: timeout: float = 60.0, reconnect: bool = True, cls: Callable[[Client, Connectable], ConnectReturn] = MISSING, - ring: bool = True, ) -> ConnectReturn: """|coro| @@ -309,8 +317,6 @@ class PrivateCall: cls: Type[:class:`~discord.VoiceProtocol`] A type that subclasses :class:`~discord.VoiceProtocol` to connect with. Defaults to :class:`~discord.VoiceClient`. - ring: :class:`bool` - Whether to ring the other user. Raises ------- @@ -326,7 +332,7 @@ class PrivateCall: :class:`~discord.VoiceProtocol` A voice client that is fully connected to the voice server. """ - return await self.channel.connect(timeout=timeout, reconnect=reconnect, cls=cls, ring=ring) + return await self.channel.connect(timeout=timeout, reconnect=reconnect, cls=cls, ring=False) @_running_only async def join( @@ -335,7 +341,6 @@ class PrivateCall: timeout: float = 60.0, reconnect: bool = True, cls: Callable[[Client, Connectable], ConnectReturn] = MISSING, - ring: bool = True, ) -> ConnectReturn: """|coro| @@ -355,8 +360,6 @@ class PrivateCall: cls: Type[:class:`~discord.VoiceProtocol`] A type that subclasses :class:`~discord.VoiceProtocol` to connect with. Defaults to :class:`~discord.VoiceClient`. - ring: :class:`bool` - Whether to ring the other user. Raises ------- @@ -372,10 +375,10 @@ class PrivateCall: :class:`~discord.VoiceProtocol` A voice client that is fully connected to the voice server. """ - return await self.connect(timeout=timeout, reconnect=reconnect, cls=cls, ring=ring) + return await self.connect(timeout=timeout, reconnect=reconnect, cls=cls) @_running_only - async def disconnect(self, **kwargs) -> None: + async def disconnect(self, force: bool = False) -> None: """|coro| Disconnects this voice client from voice. @@ -386,19 +389,19 @@ class PrivateCall: if not (client := state._get_voice_client(self.channel.me.id)): return - return await client.disconnect(**kwargs) + return await client.disconnect(force=force) @_running_only - async def leave(self, **kwargs) -> None: + async def leave(self, force: bool = False) -> None: """|coro| Disconnects this voice client from voice. This is an alias of :attr:`disconnect`. """ - return await self.disconnect(**kwargs) + return await self.disconnect(force=force) - def voice_state_for(self, user) -> Optional[VoiceState]: + def voice_state_for(self, user: abcSnowflake) -> Optional[VoiceState]: """Retrieves the :class:`VoiceState` for a specified :class:`User`. If the :class:`User` has no voice state then this function returns @@ -406,7 +409,7 @@ class PrivateCall: Parameters ------------ - user: :class:`User` + user: :class:`~discord.abc.Snowflake` The user to retrieve the voice state for. Returns @@ -426,11 +429,9 @@ class GroupCall(PrivateCall): ----------- channel: :class:`GroupChannel` The channel the group call is in. - message: Optional[:class:`Message`] - The message associated with this group call (if available). unavailable: :class:`bool` Denotes if this group call is unavailable. - ringing: List[:class:`~discord.abc.User`] + ringing: List[:class:`.abc.User`] A list of users that are currently being rung to join the call. region: :class:`str` The region the group call is being hosted in. @@ -439,35 +440,56 @@ class GroupCall(PrivateCall): The type of this attribute has changed to :class:`str`. """ + __slots__ = tuple() + if TYPE_CHECKING: channel: GroupChannel - def _update(self, *, ringing: List[int] = [], region: str = MISSING) -> None: - if region is not MISSING: - self.region = region + def _get_recipients(self) -> Set[abcUser]: + channel = self.channel + ret: Set[abcUser] = set(channel.recipients) + ret.add(channel.me) + return ret - lookup: Dict[int, abcUser] = {u.id: u for u in self.channel.recipients} - me = self.channel.me - lookup[me.id] = me - self.ringing = list(filter(None, map(lookup.get, ringing))) + @_running_only + async def ring(self, *recipients: abcSnowflake) -> None: + r"""|coro| - @property - def members(self) -> List[abcUser]: - """List[:class:`User`]: Returns all users that are currently in this call.""" - ret: List[abcUser] = [u for u in self.channel.recipients if self._is_participating(u)] - me = self.channel.me - if self._is_participating(me): - ret.append(me) + Rings the specified recipients. - return ret + Parameters + ----------- + \*recipients: :class:`~discord.abc.Snowflake` + The recipients to ring. The default is to ring all recipients. - @_running_only - async def ring(self, *recipients) -> None: - await self._state.http.ring(self._channel_id, *{r.id for r in recipients}) + Raises + ------- + HTTPException + Stopping the ringing failed. + ClientException + The call has ended. + """ + await self._state.http.ring(self.channel.id, *{r.id for r in recipients}) @_running_only - async def stop_ringing(self, *recipients) -> None: - await self._state.http.stop_ringing(self._channel_id, *{r.id for r in recipients}) + async def stop_ringing(self, *recipients: abcSnowflake) -> None: + r"""|coro| + + Stops ringing the specified recipients. + + Parameters + ----------- + \*recipients: :class:`~discord.abc.Snowflake` + The recipients to stop ringing. + + Raises + ------- + HTTPException + Ringing failed. + ClientException + The call has ended. + """ + await self._state.http.stop_ringing(self.channel.id, *{r.id for r in recipients}) Call = Union[PrivateCall, GroupCall] diff --git a/discord/channel.py b/discord/channel.py index de45989e9..1576457a8 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -2249,8 +2249,14 @@ class DMChannel(discord.abc.Messageable, discord.abc.Connectable, Hashable): await self._state.access_private_channel(self.id) return self - def _initial_ring(self): - return self._state.http.ring(self.id) + async def _initial_ring(self) -> None: + ring = self.recipient.is_friend() + if not ring: + data = await self._state.http.get_ringability(self.id) + ring = data['ringable'] + + if ring: + await self._state.http.ring(self.id) def __str__(self) -> str: if self.recipient: @@ -2322,7 +2328,7 @@ class DMChannel(discord.abc.Messageable, discord.abc.Connectable, Hashable): Parameters ----------- - obj: :class:`User` + obj: :class:`~discord.abc.Snowflake` The user to check permissions for. This parameter is ignored but kept for compatibility with other ``permissions_for`` methods. @@ -2380,7 +2386,6 @@ class DMChannel(discord.abc.Messageable, discord.abc.Connectable, Hashable): """ await self._state.http.delete_channel(self.id) - @utils.copy_doc(discord.abc.Connectable.connect) async def connect( self, *, @@ -2389,6 +2394,40 @@ class DMChannel(discord.abc.Messageable, discord.abc.Connectable, Hashable): cls: Callable[[Client, discord.abc.Connectable], ConnectReturn] = MISSING, ring: bool = True, ) -> ConnectReturn: + """|coro| + + Connects to voice and creates a :class:`~discord.VoiceClient` to establish + your connection to the voice server. + + Parameters + ----------- + timeout: :class:`float` + The timeout in seconds to wait for the voice endpoint. + reconnect: :class:`bool` + Whether the bot should automatically attempt + a reconnect if a part of the handshake fails + or the gateway goes down. + cls: Type[:class:`~discord.VoiceProtocol`] + A type that subclasses :class:`~discord.VoiceProtocol` to connect with. + Defaults to :class:`~discord.VoiceClient`. + ring: :class:`bool` + Whether to ring the other member(s) to join the call, if starting a new call. + Defaults to ``True``. + + Raises + ------- + asyncio.TimeoutError + Could not connect to the voice channel in time. + ~discord.ClientException + You are already connected to a voice channel. + ~discord.opus.OpusNotLoaded + The opus library has not been loaded. + + Returns + -------- + :class:`~discord.VoiceProtocol` + A voice client that is fully connected to the voice server. + """ await self._get_channel() call = self.call if call is None and ring: @@ -2591,7 +2630,7 @@ class GroupChannel(discord.abc.Messageable, discord.abc.Connectable, Hashable): Parameters ----------- - \*recipients: :class:`User` + \*recipients: :class:`~discord.abc.Snowflake` An argument list of users to add to this group. Raises @@ -2612,7 +2651,7 @@ class GroupChannel(discord.abc.Messageable, discord.abc.Connectable, Hashable): Parameters ----------- - \*recipients: :class:`User` + \*recipients: :class:`~discord.abc.Snowflake` An argument list of users to remove from this group. Raises @@ -2683,6 +2722,8 @@ class GroupChannel(discord.abc.Messageable, discord.abc.Connectable, Hashable): If you are the only one in the group, this deletes it as well. + There is an alias for this called :func:`close`. + Raises ------- HTTPException @@ -2690,6 +2731,22 @@ class GroupChannel(discord.abc.Messageable, discord.abc.Connectable, Hashable): """ await self._state.http.delete_channel(self.id) + async def close(self) -> None: + """|coro| + + Leave the group. + + If you are the only one in the group, this deletes it as well. + + This is an alias of :func:`leave`. + + Raises + ------- + HTTPException + Leaving the group failed. + """ + await self.leave() + async def create_invite(self, *, max_age: int = 86400) -> Invite: """|coro| @@ -2714,7 +2771,7 @@ class GroupChannel(discord.abc.Messageable, discord.abc.Connectable, Hashable): data = await self._state.http.create_group_invite(self.id, max_age=max_age) return Invite.from_incomplete(data=data, state=self._state) - @utils.copy_doc(discord.abc.Connectable.connect) + @utils.copy_doc(DMChannel.connect) async def connect( self, *, diff --git a/discord/http.py b/discord/http.py index c408bd674..29d52a65a 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1852,6 +1852,9 @@ class HTTPClient: ) -> Response[member.MemberWithUser]: return self.edit_member(guild_id=guild_id, user_id=user_id, channel_id=channel_id, reason=reason) + def get_ringability(self, channel_id: Snowflake): + return self.request(Route('GET', '/channels/{channel_id}/call', channel_id=channel_id)) + def ring(self, channel_id: Snowflake, *recipients: Snowflake) -> Response[None]: payload = {'recipients': recipients or None} diff --git a/discord/state.py b/discord/state.py index 8e0e593d0..509a92d61 100644 --- a/discord/state.py +++ b/discord/state.py @@ -79,6 +79,7 @@ from .interactions import Interaction from .permissions import Permissions, PermissionOverwrite from .member import _ClientStatus from .modal import Modal +from .member import VoiceState if TYPE_CHECKING: from .abc import PrivateChannel, Snowflake as abcSnowflake @@ -89,7 +90,6 @@ if TYPE_CHECKING: from .client import Client from .gateway import DiscordWebSocket from .calls import Call - from .member import VoiceState from .types.snowflake import Snowflake from .types.activity import Activity as ActivityPayload @@ -2098,8 +2098,8 @@ class ConnectionState: if channel is None: _log.debug('CALL_CREATE referencing unknown channel ID: %s. Discarding.', data['channel_id']) return - message = self._call_message_cache.pop((int(data['message_id'])), None) - call = channel._add_call(state=self, message=message, channel=channel, **data) + message = self._get_message(int(data['message_id'])) + call = channel._add_call(data=data, state=self, message=message, channel=channel) self._calls[channel.id] = call self.dispatch('call_create', call) @@ -2109,14 +2109,15 @@ class ConnectionState: _log.debug('CALL_UPDATE referencing unknown call (channel ID: %s). Discarding.', data['channel_id']) return old_call = copy.copy(call) - call._update(**data) + call._update(data) self.dispatch('call_update', old_call, call) def parse_call_delete(self, data) -> None: call = self._calls.pop(int(data['channel_id']), None) if call is not None: - call._deleteup() - self.dispatch('call_delete', call) + call._delete() + self._call_message_cache.pop(call._message_id, None) + self.dispatch('call_delete', call) def parse_voice_state_update(self, data: gw.VoiceStateUpdateEvent) -> None: guild = self._get_guild(utils._get_as_snowflake(data, 'guild_id')) @@ -2146,7 +2147,7 @@ class ConnectionState: user, before, after = self._update_voice_state(data, channel_id) self.dispatch('voice_state_update', user, before, after) - def parse_voice_server_update(self, data) -> None: + def parse_voice_server_update(self, data: gw.VoiceServerUpdateEvent) -> None: key_id = utils._get_as_snowflake(data, 'guild_id') if key_id is None: key_id = self.self_id diff --git a/discord/types/gateway.py b/discord/types/gateway.py index 69f0d0dd0..b424f54f8 100644 --- a/discord/types/gateway.py +++ b/discord/types/gateway.py @@ -328,6 +328,7 @@ VoiceStateUpdateEvent = GuildVoiceState class VoiceServerUpdateEvent(TypedDict): token: str guild_id: Snowflake + channel_id: Snowflake endpoint: Optional[str] diff --git a/discord/types/voice.py b/discord/types/voice.py index 8f4e2e03e..d68f237d8 100644 --- a/discord/types/voice.py +++ b/discord/types/voice.py @@ -51,7 +51,7 @@ class GuildVoiceState(_VoiceState): class VoiceState(_VoiceState, total=False): channel_id: Optional[Snowflake] - guild_id: Snowflake + guild_id: NotRequired[Optional[Snowflake]] class VoiceRegion(TypedDict): @@ -66,6 +66,7 @@ class VoiceRegion(TypedDict): class VoiceServerUpdate(TypedDict): token: str guild_id: Snowflake + channel_id: Snowflake endpoint: Optional[str] diff --git a/discord/voice_client.py b/discord/voice_client.py index 4842b4647..b327c3bd3 100644 --- a/discord/voice_client.py +++ b/discord/voice_client.py @@ -51,7 +51,7 @@ from .backoff import ExponentialBackoff from .gateway import * from .errors import ClientException, ConnectionClosed from .player import AudioPlayer, AudioSource -from .utils import MISSING +from .utils import _get_as_snowflake, MISSING if TYPE_CHECKING: from .client import Client @@ -311,7 +311,7 @@ class VoiceClient(VoiceProtocol): return self.token = data['token'] - self.server_id = int(data['guild_id']) + self.server_id = _get_as_snowflake(data, 'guild_id') or int(data['channel_id']) endpoint = data.get('endpoint') if endpoint is None or self.token is None: @@ -386,7 +386,7 @@ class VoiceClient(VoiceProtocol): for i in range(5): self.prepare_handshake() - # This has to be created before we start the flow. + # This has to be created before we start the flow futures = [ self._voice_state_complete.wait(), self._voice_server_complete.wait(),