Browse Source

Fix call handling

pull/10109/head
dolfies 3 years ago
parent
commit
e64d0e71dd
  1. 204
      discord/calls.py
  2. 71
      discord/channel.py
  3. 3
      discord/http.py
  4. 15
      discord/state.py
  5. 1
      discord/types/gateway.py
  6. 3
      discord/types/voice.py
  7. 6
      discord/voice_client.py

204
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]

71
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,
*,

3
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}

15
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

1
discord/types/gateway.py

@ -328,6 +328,7 @@ VoiceStateUpdateEvent = GuildVoiceState
class VoiceServerUpdateEvent(TypedDict):
token: str
guild_id: Snowflake
channel_id: Snowflake
endpoint: Optional[str]

3
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]

6
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(),

Loading…
Cancel
Save