diff --git a/CHANGELOG.md b/CHANGELOG.md index 79b8942837..2fec184c62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ These changes are available on the `master` branch, but have not yet been releas - Support for **Python 3.14**. ([#2948](https://github.com/Pycord-Development/pycord/pull/2948)) +- Added the ability to fetch and cache voice channel status and start time on demand, + and at startup. ([#3210](https://github.com/Pycord-Development/pycord/pull/3210)) +- Added `voice_start_time` to `VoiceChannel` and `StageChannel`, as well as the + corresponding `on_voice_channel_start_time_update`. + ([#3210](https://github.com/Pycord-Development/pycord/pull/3210)) ### Changed diff --git a/discord/channel.py b/discord/channel.py index 2a6d5182ac..7d7c5210b9 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -1603,6 +1603,8 @@ class VocalGuildChannel(discord.abc.Connectable, discord.abc.GuildChannel, Hasha "last_message_id", "flags", "nsfw", + "status", + "_voice_start_time", ) def __init__( @@ -1615,6 +1617,7 @@ def __init__( self._state: ConnectionState = state self.id: int = int(data["id"]) self._update(guild, data) + self._update_status(status=data.get("status", MISSING)) def _get_voice_client_key(self) -> tuple[int, str]: return self.guild.id, "guild_id" @@ -1650,6 +1653,24 @@ def _update( self.nsfw: bool = data.get("nsfw", False) self._fill_overwrites(data) + def _update_status( + self, *, status: str | None = MISSING, voice_start_time: int | None = MISSING + ): + if status is not MISSING: + self.status = status + if voice_start_time is not MISSING: + self._voice_start_time = voice_start_time + + @property + def voice_start_time(self) -> datetime.datetime | None: + """:class:`datetime.datetime` | :class:`None`: The time that the voice session started. + + .. versionadded:: 2.9 + """ + if self._voice_start_time is None: + return None + return datetime.datetime.fromtimestamp(self._voice_start_time, tz=datetime.UTC) + @property def _sorting_bucket(self) -> int: return ChannelType.voice.value @@ -1781,13 +1802,9 @@ def __init__( data: VoiceChannelPayload, ): self.status: str | None = None + self._voice_start_time: int | None = None super().__init__(state=state, guild=guild, data=data) - def _update(self, guild: Guild, data: VoiceChannelPayload): - super()._update(guild, data) - if data.get("status"): - self.status = data.get("status") - def __repr__(self) -> str: attrs = [ ("id", self.id), @@ -2233,6 +2250,7 @@ async def set_status( Sets the status of the voice channel. You must have the :attr:`~Permissions.set_voice_channel_status` permission to use this. + If the bot is not connected to the voice channel, this also requires :attr:`~Permissions.manage_channels`. Parameters ---------- @@ -2339,6 +2357,16 @@ class StageChannel(discord.abc.Messageable, VocalGuildChannel): __slots__ = ("topic",) + def __init__( + self, + *, + state: ConnectionState, + guild: Guild, + data: StageChannelPayload, + ): + self._voice_start_time: int | None = None + super().__init__(state=state, guild=guild, data=data) + def _update(self, guild: Guild, data: StageChannelPayload) -> None: super()._update(guild, data) self.topic = data.get("topic") diff --git a/discord/client.py b/discord/client.py index f227ccf1df..47132db03a 100644 --- a/discord/client.py +++ b/discord/client.py @@ -233,6 +233,10 @@ class Client: Whether to automatically fetch and cache the default soundboard sounds on startup. Defaults to ``True``. .. versionadded:: 2.8 + cache_channel_info: :class:`bool` + Whether to automatically request and cache voice channel statuses on startup. Defaults to ``False``. + + .. versionadded:: 2.9 Attributes ----------- @@ -719,6 +723,10 @@ async def connect(self, *, reconnect: bool = True) -> None: except ReconnectWebSocket as e: _log.info("Got a request to %s the websocket.", e.op) self.dispatch("disconnect") + if not e.resume: + # Since we aren't resuming, channel info can fall out of date + # So we re-request it + self._connection._request_channel_info = True ws_params.update( sequence=self.ws.sequence, resume=e.resume, diff --git a/discord/gateway.py b/discord/gateway.py index b9b28f887d..3b76a4c3fd 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -39,8 +39,10 @@ import aiohttp from . import utils +from .abc import Snowflake from .activity import BaseActivity from .errors import ConnectionClosed, InvalidArgument +from .types.channel import RequestChannelInfoField if TYPE_CHECKING: from typing_extensions import Self @@ -283,6 +285,7 @@ class DiscordWebSocket: HEARTBEAT_ACK = 11 GUILD_SYNC = 12 REQUEST_SOUNDBOARD_SOUNDS = 31 + REQUEST_CHANNEL_INFO = 43 if TYPE_CHECKING: token: str | None @@ -772,6 +775,21 @@ async def request_soundboard_sounds(self, guild_ids): _log.debug("Requesting soundboard sounds for guilds %s.", guild_ids) await self.send_as_json(payload) + async def request_channel_info( + self, guild_id: int, fields: list[RequestChannelInfoField] + ): + payload = { + "op": self.REQUEST_CHANNEL_INFO, + "d": {"guild_id": guild_id, "fields": fields}, + } + + _log.debug( + "Requesting channel info for guild %s with fields %s.", + guild_id, + ", ".join(fields), + ) + await self.send_as_json(payload) + async def close(self, code: int = 4000) -> None: if self._keep_alive: self._keep_alive.stop() diff --git a/discord/guild.py b/discord/guild.py index 4ecbfe57bd..441c597720 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -87,6 +87,7 @@ from .monetization import Entitlement from .onboarding import Onboarding from .permissions import PermissionOverwrite +from .raw_models import ChannelInfo from .role import Role, RoleColours from .scheduled_events import ScheduledEvent, ScheduledEventLocation from .soundboard import SoundboardSound @@ -3954,6 +3955,25 @@ async def chunk(self, *, cache: bool = True) -> None: if not self._state.is_guild_evicted(self): return await self._state.chunk_guild(self, cache=cache) + async def request_channel_info( + self, *, cache: bool = True + ) -> None | list[ChannelInfo]: + """|coro| + + Requests all channel statuses for this guild over the websocket. + + .. versionadded:: 2.9 + + Parameters + ---------- + cache: :class:`bool` + Whether to cache the channel statuses as well. + """ + + if not self._state.is_guild_evicted(self): + return await self._state.request_guild_channel_info(self, cache=cache) + return None + async def query_members( self, query: str | None = None, diff --git a/discord/raw_models.py b/discord/raw_models.py index 9a50795573..7b02007d55 100644 --- a/discord/raw_models.py +++ b/discord/raw_models.py @@ -70,6 +70,7 @@ ThreadMembersUpdateEvent, ThreadUpdateEvent, TypingEvent, + VoiceChannelStartTimeUpdateEvent, VoiceChannelStatusUpdateEvent, VoiceServerUpdateEvent, VoiceStateEvent, @@ -98,7 +99,9 @@ "RawSoundboardSoundDeleteEvent", "RawVoiceServerUpdateEvent", "RawVoiceStateUpdateEvent", + "RawVoiceChannelStartTimeUpdateEvent", "RawMemberUpdateEvent", + "ChannelInfo", ) @@ -1030,3 +1033,54 @@ def __init__(self, data: MemberUpdateEvent, member: Member) -> None: self.data: MemberUpdateEvent = data self.cached_member: Member | None = None self.member: Member = member + + +class RawVoiceChannelStartTimeUpdateEvent(_RawReprMixin): + """Represents the payload for an :func:`on_raw_voice_channel_start_time_update` event. + + .. versionadded:: 2.9 + + Attributes + ---------- + id: :class:`int` + The channel ID where the voice channel start time update originated from. + guild_id: :class:`int` + The guild ID where the voice channel start time update originated from. + voice_start_time: Optional[:class:`datetime.datetime`] + The new new voice channel start time. + data: :class:`dict` + The raw data sent by the `gateway `__. + """ + + __slots__ = ("id", "guild_id", "voice_start_time", "data") + + def __init__(self, data: VoiceChannelStartTimeUpdateEvent) -> None: + self.id: int = int(data["id"]) + self.guild_id: int = int(data["guild_id"]) + self.voice_start_time: datetime.datetime | None = ( + datetime.datetime.fromtimestamp(data["voice_start_time"], tz=datetime.UTC) + if data.get("voice_start_time") + else None + ) + self.data: VoiceChannelStartTimeUpdateEvent = data + + +class ChannelInfo(_RawReprMixin): + """Represents the gateway response to a request for channel information. + + .. versionadded:: 2.9 + + Attributes + ---------- + id: :class:`int` + The ID of the channel this info is associated with. + status: :class:`str` | None + The voice channel status. + voice_start_time: :class:`int` | None + The Unix timestamp (in seconds) of when the voice session started. + """ + + def __init__(self, data): + self.id: int = int(data["id"]) + self.status: str | None = data.get("status", utils.MISSING) + self.voice_start_time: int | None = data.get("voice_start_time", utils.MISSING) diff --git a/discord/state.py b/discord/state.py index 574c973c52..99f76d76ef 100644 --- a/discord/state.py +++ b/discord/state.py @@ -41,6 +41,7 @@ Sequence, TypeVar, Union, + overload, ) from . import utils @@ -64,6 +65,7 @@ from .partial_emoji import PartialEmoji from .poll import Poll, PollAnswerCount from .raw_models import * +from .raw_models import ChannelInfo from .role import Role from .scheduled_events import ScheduledEvent from .soundboard import PartialSoundboardSound, SoundboardSound @@ -145,6 +147,65 @@ def done(self) -> None: future.set_result(self.buffer) +class ChannelInfoRequest: + def __init__( + self, + guild_id: int, + loop: asyncio.AbstractEventLoop, + resolver: Callable[[int], Any], + *, + cache: bool = True, + ) -> None: + self.guild_id: int = guild_id + self.resolver: Callable[[int], Any] = resolver + self.loop: asyncio.AbstractEventLoop = loop + self.cache: bool = cache + self.channel_info: list[ChannelInfo] = [] + self.waiters: list[asyncio.Future[list[ChannelInfo]]] = [] + + def parse_response(self, channel_info: list[ChannelInfo]) -> None: + self.channel_info = channel_info + if self.cache: + guild = self.resolver(self.guild_id) + if guild is None: + return + + for channel in channel_info: + existing = guild.get_channel(channel.id) + # check voice_start_time + # since stage channels can have that, but not status + if ( + existing is not None + and channel.voice_start_time is not utils.MISSING + ): + try: + existing._update_status( + status=channel.status, + voice_start_time=channel.voice_start_time, + ) + except AttributeError: + # Failsafe, discord sends *all* channels, not just voice + # so if this runs on say, a text channel, we just ignore it + pass + + for future in self.waiters: + if not future.done(): + future.set_result(self.channel_info) + + async def wait(self) -> list[ChannelInfo]: + future = self.loop.create_future() + self.waiters.append(future) + try: + return await future + finally: + self.waiters.remove(future) + + def get_future(self) -> asyncio.Future[list[ChannelInfo]]: + future = self.loop.create_future() + self.waiters.append(future) + return future + + _log = logging.getLogger(__name__) @@ -259,6 +320,10 @@ def __init__( True, # TODO(Paillat-dev): Don't cache default sounds by default ) + self.cache_channel_info: bool = options.get("cache_channel_info", False) + self._request_channel_info: bool = self.cache_channel_info + self._channel_info_requests: dict[int | str, ChannelInfoRequest] = {} + self.parsers = parsers = {} for attr, func in inspect.getmembers(self): if attr.startswith("parse_"): @@ -314,6 +379,18 @@ def process_chunk_requests( for key in removed: del self._chunk_requests[key] + def process_info_requests( + self, guild_id: int, channel_info: list[ChannelInfo] + ) -> None: + removed = [] + for key, request in self._channel_info_requests.items(): + if request.guild_id == guild_id: + request.parse_response(channel_info) + removed.append(key) + + for key in removed: + del self._channel_info_requests[key] + def call_handlers(self, key: str, *args: Any, **kwargs: Any) -> None: try: func = self.handlers[key] @@ -638,23 +715,47 @@ async def _delay_ready(self) -> None: except asyncio.TimeoutError: break else: - if self._guild_needs_chunking(guild): - future = await self.chunk_guild(guild, wait=False) - states.append((guild, future)) + if ( + needs_chunk := self._guild_needs_chunking(guild) + ) or self._request_channel_info: + if needs_chunk and self._request_channel_info: + chunk_future = await self.chunk_guild(guild, wait=False) + info_future = await self.request_guild_channel_info( + guild, wait=False + ) + states.append((guild, chunk_future, info_future)) + elif needs_chunk: + future = await self.chunk_guild(guild, wait=False) + states.append((guild, future, None)) + else: + future = await self.request_guild_channel_info( + guild, wait=False + ) + states.append((guild, None, future)) elif guild.unavailable is False: self.dispatch("guild_available", guild) else: self.dispatch("guild_join", guild) - for guild, future in states: - try: - await asyncio.wait_for(future, timeout=5.0) - except asyncio.TimeoutError: - _log.warning( - "Shard ID %s timed out waiting for chunks for guild_id %s.", - guild.shard_id, - guild.id, - ) + for guild, chunk_future, info_future in states: + if chunk_future: + try: + await asyncio.wait_for(chunk_future, timeout=5.0) + except asyncio.TimeoutError: + _log.warning( + "Shard ID %s timed out waiting for chunks for guild_id %s.", + guild.shard_id, + guild.id, + ) + if info_future: + try: + await asyncio.wait_for(info_future, timeout=5.0) + except asyncio.TimeoutError: + _log.warning( + "Shard ID %s timed out waiting for channel info for guild_id %s.", + guild.shard_id, + guild.id, + ) if guild.unavailable is False: self.dispatch("guild_available", guild) @@ -1131,7 +1232,7 @@ def parse_thread_create(self, data) -> None: "join_timestamp": data["thread_metadata"][ "create_timestamp" ], - "flags": utils.MISSING, + "flags": utils.utils.MISSING, }, ) ) @@ -1447,6 +1548,37 @@ async def chunk_guild(self, guild, *, wait=True, cache=None): return await request.wait() return request.get_future() + @overload + async def request_guild_channel_info( + self, guild: Guild, *, wait: bool = True, cache: bool | None = None + ) -> list[ChannelInfo]: ... + + @overload + async def request_guild_channel_info( + self, guild: Guild, *, wait: bool = False, cache: bool | None = None + ) -> asyncio.Future[list[ChannelInfo]]: ... + + async def request_guild_channel_info( + self, guild: Guild, *, wait: bool = True, cache: bool | None = None + ) -> asyncio.Future[list[ChannelInfo]] | list[ChannelInfo]: + cache = cache or self.cache_channel_info + request = self._channel_info_requests.get(guild.id) + if request is None: + self._channel_info_requests[guild.id] = request = ChannelInfoRequest( + guild.id, + self.loop, + self._get_guild, + cache=cache, + ) + ws = self._get_websocket(guild.id) + await ws.request_channel_info( + guild.id, fields=["status", "voice_start_time"] + ) + + if wait: + return await request.wait() + return request.get_future() + async def _chunk_and_dispatch(self, guild, unavailable): try: await asyncio.wait_for(self.chunk_guild(guild), timeout=60.0) @@ -1638,6 +1770,16 @@ def parse_guild_members_chunk(self, data) -> None: complete = data.get("chunk_index", 0) + 1 == data.get("chunk_count") self.process_chunk_requests(guild_id, data.get("nonce"), members, complete) + def parse_channel_info(self, data) -> None: + guild_id = int(data["guild_id"]) + channel_info = [ChannelInfo(c) for c in data["channels"]] + _log.debug( + "Processed channel info for %s channels in guild ID %s.", + len(channel_info), + guild_id, + ) + self.process_info_requests(guild_id, channel_info) + def parse_guild_scheduled_event_create(self, data) -> None: guild = self._get_guild(int(data["guild_id"])) if guild is None: @@ -1950,7 +2092,7 @@ def parse_voice_channel_status_update(self, data) -> None: channel = guild.get_channel(channel_id) if channel is not None: old_status = channel.status - channel.status = data.get("status", None) + channel._update_status(status=data.get("status")) self.dispatch( "voice_channel_status_update", channel, old_status, channel.status ) @@ -1965,6 +2107,33 @@ def parse_voice_channel_status_update(self, data) -> None: data["guild_id"], ) + def parse_voice_channel_start_time_update(self, data) -> None: + raw = RawVoiceChannelStatusUpdateEvent(data) + self.dispatch("raw_voice_channel_start_time_update", raw) + guild = self._get_guild(int(data["guild_id"])) + channel_id = int(data["id"]) + if guild is not None: + channel = guild.get_channel(channel_id) + if channel is not None: + old_voice_start_time = channel.voice_start_time + channel._update_status(voice_start_time=data.get("voice_start_time")) + self.dispatch( + "voice_channel_start_time_update", + channel, + old_voice_start_time, + channel.voice_start_time, + ) + else: + _log.debug( + "VOICE_CHANNEL_START_TIME_UPDATE referencing an unknown channel ID: %s. Discarding.", + channel_id, + ) + else: + _log.debug( + "VOICE_CHANNEL_START_TIME_UPDATE referencing unknown guild ID: %s. Discarding.", + data["guild_id"], + ) + def parse_typing_start(self, data) -> None: raw = RawTypingEvent(data) diff --git a/discord/types/channel.py b/discord/types/channel.py index d4661cf8c4..a9c6fd18df 100644 --- a/discord/types/channel.py +++ b/discord/types/channel.py @@ -194,3 +194,6 @@ class VoiceChannelEffectSendEvent(TypedDict): animation_id: NotRequired[int] sound_id: NotRequired[Snowflake | int] sound_volume: NotRequired[float] + + +RequestChannelInfoField = Literal["status", "voice_start_time"] diff --git a/discord/types/raw_models.py b/discord/types/raw_models.py index 7ec8a446ca..218792ea1c 100644 --- a/discord/types/raw_models.py +++ b/discord/types/raw_models.py @@ -140,6 +140,12 @@ class VoiceChannelStatusUpdateEvent(TypedDict): status: NotRequired[str] +class VoiceChannelStartTimeUpdateEvent(TypedDict): + id: Snowflake + guild_id: Snowflake + voice_start_time: NotRequired[int] + + class ThreadMembersUpdateEvent(TypedDict): id: Snowflake guild_id: Snowflake diff --git a/docs/api/events.rst b/docs/api/events.rst index 9242f8ff5d..bf9f3f60ee 100644 --- a/docs/api/events.rst +++ b/docs/api/events.rst @@ -1415,6 +1415,28 @@ Voice Channel Status Update :param payload: The raw voice channel status update payload. :type payload: :class:`RawVoiceChannelStatusUpdateEvent` +.. function:: on_voice_channel_start_time_update(channel, before, after) + + Called when the start time for a voice channel is updated. + + .. versionadded:: 2.9 + + :param channel: The channel where the voice channel start time update originated from. + :type channel: :class:`abc.GuildChannel` + :param before: The old voice channel start time. + :type before: Optional[:class:`datetime.datetime`] + :param after: The new voice channel start time. + :type after: Optional[:class:`datetime.datetime`] + +.. function:: on_raw_voice_channel_start_time_update(payload) + + Called when the start time for a voice channel is updated. + + .. versionadded:: 2.9 + + :param payload: The raw voice channel start time update payload. + :type payload: :class:`RawVoiceChannelStartTimeUpdateEvent` + Voice Channel Effects --------------------- .. function:: on_voice_channel_effect_send(event) diff --git a/docs/api/models.rst b/docs/api/models.rst index 12094bc698..4fbf437933 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -726,6 +726,11 @@ Events .. autoclass:: VoiceChannelEffectSendEvent() :members: +.. attributetable:: ChannelInfo + +.. autoclass:: ChannelInfo() + :members: + Webhooks