From b651abc394b5fff8af3ea4dedacb2ada2e321b49 Mon Sep 17 00:00:00 2001 From: plun1331 Date: Mon, 20 Apr 2026 16:15:53 -0700 Subject: [PATCH 01/15] feat: voice channel info from gateway --- discord/channel.py | 100 ++++++++++++------- discord/client.py | 8 ++ discord/gateway.py | 12 +++ discord/guild.py | 23 +++++ discord/raw_models.py | 51 +++++++++- discord/state.py | 191 +++++++++++++++++++++++++++++++----- discord/types/channel.py | 3 + discord/types/raw_models.py | 6 ++ 8 files changed, 333 insertions(+), 61 deletions(-) diff --git a/discord/channel.py b/discord/channel.py index 2a6d5182ac..d8aef8c29b 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -194,9 +194,9 @@ def from_data(cls, *, state: ConnectionState, data: ForumTagPayload) -> ForumTag def to_dict(self) -> dict[str, Any]: payload: dict[str, Any] = { - "name": self.name, - "moderated": self.moderated, - } | self.emoji._to_forum_reaction_payload() + "name": self.name, + "moderated": self.moderated, + } | self.emoji._to_forum_reaction_payload() if self.id: payload["id"] = self.id @@ -804,10 +804,12 @@ async def edit( default_thread_slowmode_delay: int = ..., type: ChannelType = ..., overwrites: Mapping[Role | Member | Snowflake, PermissionOverwrite] = ..., - ) -> TextChannel | None: ... + ) -> TextChannel | None: + ... @overload - async def edit(self) -> TextChannel | None: ... + async def edit(self) -> TextChannel | None: + ... async def edit(self, *, reason=None, **options): """|coro| @@ -947,7 +949,7 @@ async def create_thread( self.id, name=name, auto_archive_duration=auto_archive_duration - or self.default_auto_archive_duration, + or self.default_auto_archive_duration, type=type.value, rate_limit_per_user=slowmode_delay or 0, invitable=invitable, @@ -959,7 +961,7 @@ async def create_thread( message.id, name=name, auto_archive_duration=auto_archive_duration - or self.default_auto_archive_duration, + or self.default_auto_archive_duration, rate_limit_per_user=slowmode_delay or 0, reason=reason, ) @@ -1110,7 +1112,7 @@ async def edit( category: CategoryChannel | None = ..., slowmode_delay: int = ..., default_auto_archive_duration: ( - ThreadArchiveDuration | ThreadArchiveDurationEnum + ThreadArchiveDuration | ThreadArchiveDurationEnum ) = ..., default_thread_slowmode_delay: int = ..., default_sort_order: SortOrder = ..., @@ -1118,10 +1120,12 @@ async def edit( available_tags: list[ForumTag] = ..., require_tag: bool = ..., overwrites: Mapping[Role | Member | Snowflake, PermissionOverwrite] = ..., - ) -> ForumChannel | None: ... + ) -> ForumChannel | None: + ... @overload - async def edit(self) -> ForumChannel | None: ... + async def edit(self) -> ForumChannel | None: + ... async def edit(self, *, reason=None, **options): """|coro| @@ -1315,7 +1319,7 @@ async def create_thread( if allowed_mentions is None: allowed_mentions = ( - state.allowed_mentions and state.allowed_mentions.to_dict() + state.allowed_mentions and state.allowed_mentions.to_dict() ) elif state.allowed_mentions is not None: allowed_mentions = state.allowed_mentions.merge(allowed_mentions).to_dict() @@ -1375,7 +1379,7 @@ async def create_thread( stickers=stickers, components=components, auto_archive_duration=auto_archive_duration - or self.default_auto_archive_duration, + or self.default_auto_archive_duration, rate_limit_per_user=slowmode_delay or self.slowmode_delay, applied_tags=applied_tags, flags=flags.value, @@ -1499,7 +1503,8 @@ async def edit( require_tag: bool = ..., hide_media_download_options: bool = ..., overwrites: Mapping[Role | Member | Snowflake, PermissionOverwrite] = ..., - ) -> ForumChannel | None: ... + ) -> ForumChannel | None: + ... async def edit(self, *, reason=None, **options): """|coro| @@ -1603,6 +1608,8 @@ class VocalGuildChannel(discord.abc.Connectable, discord.abc.GuildChannel, Hasha "last_message_id", "flags", "nsfw", + "status", + "_voice_start_time", ) def __init__( @@ -1615,6 +1622,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 +1658,19 @@ 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): + print(f"Updating status for {self.id}", status, voice_start_time) + 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: + 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), @@ -2091,10 +2108,12 @@ async def edit( slowmode_delay: int = ..., nsfw: bool = ..., reason: str | None = ..., - ) -> VoiceChannel | None: ... + ) -> VoiceChannel | None: + ... @overload - async def edit(self) -> VoiceChannel | None: ... + async def edit(self) -> VoiceChannel | None: + ... async def edit(self, *, reason=None, **options): """|coro| @@ -2339,6 +2358,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") @@ -2377,8 +2406,8 @@ def speakers(self) -> list[Member]: member for member in self.members if member.voice - and not member.voice.suppress - and member.voice.requested_to_speak_at is None + and not member.voice.suppress + and member.voice.requested_to_speak_at is None ] @property @@ -2776,10 +2805,12 @@ async def edit( rtc_region: VoiceRegion | None = ..., video_quality_mode: VideoQualityMode = ..., reason: str | None = ..., - ) -> StageChannel | None: ... + ) -> StageChannel | None: + ... @overload - async def edit(self) -> StageChannel | None: ... + async def edit(self) -> StageChannel | None: + ... async def edit(self, *, reason=None, **options): """|coro| @@ -2948,10 +2979,12 @@ async def edit( position: int = ..., overwrites: Mapping[Role | Member, PermissionOverwrite] = ..., reason: str | None = ..., - ) -> CategoryChannel | None: ... + ) -> CategoryChannel | None: + ... @overload - async def edit(self) -> CategoryChannel | None: ... + async def edit(self) -> CategoryChannel | None: + ... async def edit(self, *, reason=None, **options): """|coro| @@ -3519,7 +3552,8 @@ class VoiceChannelEffectAnimation(NamedTuple): type: VoiceChannelEffectAnimationType -class VoiceChannelSoundEffect(PartialSoundboardSound): ... +class VoiceChannelSoundEffect(PartialSoundboardSound): + ... class VoiceChannelEffectSendEvent: @@ -3622,9 +3656,9 @@ def _channel_factory(channel_type: int): def _threaded_channel_factory(channel_type: int): cls, value = _channel_factory(channel_type) if value in ( - ChannelType.private_thread, - ChannelType.public_thread, - ChannelType.news_thread, + ChannelType.private_thread, + ChannelType.public_thread, + ChannelType.news_thread, ): return Thread, value return cls, value @@ -3633,9 +3667,9 @@ def _threaded_channel_factory(channel_type: int): def _threaded_guild_channel_factory(channel_type: int): cls, value = _guild_channel_factory(channel_type) if value in ( - ChannelType.private_thread, - ChannelType.public_thread, - ChannelType.news_thread, + ChannelType.private_thread, + ChannelType.public_thread, + ChannelType.news_thread, ): return Thread, value return cls, value diff --git a/discord/client.py b/discord/client.py index f227ccf1df..84b4b18bd2 100644 --- a/discord/client.py +++ b/discord/client.py @@ -232,6 +232,10 @@ class Client: cache_default_sounds: :class:`bool` 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 channel statuses on startup. Defaults to ``False``. + .. versionadded:: 2.8 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..6f4ffb27e9 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,15 @@ 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..e2cd5dee5e 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,28 @@ 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 channel_info(self, *, cache: bool = True) -> None | list[ChannelInfo]: + """|coro| + + Requests all channel statuses for this guild over the websocket. + + .. versionadded:: 1.5 + + Parameters + ---------- + cache: :class:`bool` + Whether to cache the channel statuses as well. + + Raises + ------ + ClientException + The members intent is not enabled. + """ + + 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..12c979e20d 100644 --- a/discord/raw_models.py +++ b/discord/raw_models.py @@ -52,8 +52,8 @@ from .types.channel import VoiceChannelEffectSendEvent as VoiceChannelEffectSend from .types.member import MemberUpdateEvent from .types.raw_models import ( - AuditLogEntryEvent, - ) + AuditLogEntryEvent, VoiceChannelStartTimeUpdateEvent, +) from .types.raw_models import AutoModActionExecutionEvent as AutoModActionExecution from .types.raw_models import ( BulkMessageDeleteEvent, @@ -98,6 +98,7 @@ "RawSoundboardSoundDeleteEvent", "RawVoiceServerUpdateEvent", "RawVoiceStateUpdateEvent", + "RawVoiceChannelStartTimeUpdateEvent", "RawMemberUpdateEvent", ) @@ -487,6 +488,32 @@ def __init__(self, data: VoiceChannelStatusUpdateEvent) -> None: self.data: VoiceChannelStatusUpdateEvent = data +class RawVoiceChannelStartTimeUpdateEvent(_RawReprMixin): + """Represents the payload for an :func:`on_raw_voice_channel_start_time_update` event. + + .. versionadded:: 2.5 + + 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 RawTypingEvent(_RawReprMixin): """Represents the payload for a :func:`on_raw_typing` event. @@ -1030,3 +1057,23 @@ def __init__(self, data: MemberUpdateEvent, member: Member) -> None: self.data: MemberUpdateEvent = data self.cached_member: Member | None = None self.member: Member = member + + +class ChannelInfo(_RawReprMixin): + """Represents the gateway response to a request for channel information. + + .. versionadded:: 2.8 + + 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..640ab350be 100644 --- a/discord/state.py +++ b/discord/state.py @@ -31,9 +31,10 @@ import itertools import logging import os +from asyncio import Future from collections import OrderedDict, deque from typing import ( - TYPE_CHECKING, + overload, TYPE_CHECKING, Any, Callable, Coroutine, @@ -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,59 @@ 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__) @@ -193,7 +248,7 @@ def __init__( allowed_mentions = options.get("allowed_mentions") if allowed_mentions is not None and not isinstance( - allowed_mentions, AllowedMentions + allowed_mentions, AllowedMentions ): raise TypeError("allowed_mentions parameter must be AllowedMentions") @@ -259,6 +314,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 +373,16 @@ 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] @@ -546,9 +615,9 @@ def _add_guild_from_data(self, data: GuildPayload) -> Guild: def _guild_needs_chunking(self, guild: Guild) -> bool: # If presences are enabled then we get back the old guild.large behaviour return ( - self._chunk_guilds - and not guild.chunked - and not (self._intents.presences and not guild.large) + self._chunk_guilds + and not guild.chunked + and not (self._intents.presences and not guild.large) ) def _get_guild_channel( @@ -638,23 +707,41 @@ 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) @@ -760,10 +847,10 @@ def parse_message_create(self, data) -> None: self._messages.append(message) # we ensure that the channel is either a TextChannel, VoiceChannel, StageChannel, or Thread if channel and channel.__class__ in ( - TextChannel, - VoiceChannel, - StageChannel, - Thread, + TextChannel, + VoiceChannel, + StageChannel, + Thread, ): channel.last_message_id = message.id # type: ignore @@ -1131,7 +1218,7 @@ def parse_thread_create(self, data) -> None: "join_timestamp": data["thread_metadata"][ "create_timestamp" ], - "flags": utils.MISSING, + "flags": utils.utils.MISSING, }, ) ) @@ -1447,6 +1534,28 @@ 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) # This is ignored upstream + 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 +1747,12 @@ 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: @@ -1905,9 +2020,9 @@ def parse_voice_state_update(self, data) -> None: if member is not None: if flags.voice: if ( - channel_id is None - and flags._voice_only - and member.id != self_id + channel_id is None + and flags._voice_only + and member.id != self_id ): # Only remove from cache if we only have the voice flag enabled # Member doesn't meet the Snowflake protocol currently @@ -1950,7 +2065,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 +2080,30 @@ 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 From a302973b0b6e952483cc06cca7ee9a5f9d8a57ce Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:19:14 +0000 Subject: [PATCH 02/15] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/channel.py | 72 ++++++++++++++++-------------------- discord/gateway.py | 12 ++++-- discord/raw_models.py | 12 ++++-- discord/state.py | 86 +++++++++++++++++++++++++++++-------------- 4 files changed, 107 insertions(+), 75 deletions(-) diff --git a/discord/channel.py b/discord/channel.py index d8aef8c29b..a0dfa6b94c 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -194,9 +194,9 @@ def from_data(cls, *, state: ConnectionState, data: ForumTagPayload) -> ForumTag def to_dict(self) -> dict[str, Any]: payload: dict[str, Any] = { - "name": self.name, - "moderated": self.moderated, - } | self.emoji._to_forum_reaction_payload() + "name": self.name, + "moderated": self.moderated, + } | self.emoji._to_forum_reaction_payload() if self.id: payload["id"] = self.id @@ -804,12 +804,10 @@ async def edit( default_thread_slowmode_delay: int = ..., type: ChannelType = ..., overwrites: Mapping[Role | Member | Snowflake, PermissionOverwrite] = ..., - ) -> TextChannel | None: - ... + ) -> TextChannel | None: ... @overload - async def edit(self) -> TextChannel | None: - ... + async def edit(self) -> TextChannel | None: ... async def edit(self, *, reason=None, **options): """|coro| @@ -949,7 +947,7 @@ async def create_thread( self.id, name=name, auto_archive_duration=auto_archive_duration - or self.default_auto_archive_duration, + or self.default_auto_archive_duration, type=type.value, rate_limit_per_user=slowmode_delay or 0, invitable=invitable, @@ -961,7 +959,7 @@ async def create_thread( message.id, name=name, auto_archive_duration=auto_archive_duration - or self.default_auto_archive_duration, + or self.default_auto_archive_duration, rate_limit_per_user=slowmode_delay or 0, reason=reason, ) @@ -1112,7 +1110,7 @@ async def edit( category: CategoryChannel | None = ..., slowmode_delay: int = ..., default_auto_archive_duration: ( - ThreadArchiveDuration | ThreadArchiveDurationEnum + ThreadArchiveDuration | ThreadArchiveDurationEnum ) = ..., default_thread_slowmode_delay: int = ..., default_sort_order: SortOrder = ..., @@ -1120,12 +1118,10 @@ async def edit( available_tags: list[ForumTag] = ..., require_tag: bool = ..., overwrites: Mapping[Role | Member | Snowflake, PermissionOverwrite] = ..., - ) -> ForumChannel | None: - ... + ) -> ForumChannel | None: ... @overload - async def edit(self) -> ForumChannel | None: - ... + async def edit(self) -> ForumChannel | None: ... async def edit(self, *, reason=None, **options): """|coro| @@ -1319,7 +1315,7 @@ async def create_thread( if allowed_mentions is None: allowed_mentions = ( - state.allowed_mentions and state.allowed_mentions.to_dict() + state.allowed_mentions and state.allowed_mentions.to_dict() ) elif state.allowed_mentions is not None: allowed_mentions = state.allowed_mentions.merge(allowed_mentions).to_dict() @@ -1379,7 +1375,7 @@ async def create_thread( stickers=stickers, components=components, auto_archive_duration=auto_archive_duration - or self.default_auto_archive_duration, + or self.default_auto_archive_duration, rate_limit_per_user=slowmode_delay or self.slowmode_delay, applied_tags=applied_tags, flags=flags.value, @@ -1503,8 +1499,7 @@ async def edit( require_tag: bool = ..., hide_media_download_options: bool = ..., overwrites: Mapping[Role | Member | Snowflake, PermissionOverwrite] = ..., - ) -> ForumChannel | None: - ... + ) -> ForumChannel | None: ... async def edit(self, *, reason=None, **options): """|coro| @@ -1658,7 +1653,9 @@ 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): + def _update_status( + self, *, status: str | None = MISSING, voice_start_time: int | None = MISSING + ): print(f"Updating status for {self.id}", status, voice_start_time) if status is not MISSING: self.status = status @@ -2108,12 +2105,10 @@ async def edit( slowmode_delay: int = ..., nsfw: bool = ..., reason: str | None = ..., - ) -> VoiceChannel | None: - ... + ) -> VoiceChannel | None: ... @overload - async def edit(self) -> VoiceChannel | None: - ... + async def edit(self) -> VoiceChannel | None: ... async def edit(self, *, reason=None, **options): """|coro| @@ -2406,8 +2401,8 @@ def speakers(self) -> list[Member]: member for member in self.members if member.voice - and not member.voice.suppress - and member.voice.requested_to_speak_at is None + and not member.voice.suppress + and member.voice.requested_to_speak_at is None ] @property @@ -2805,12 +2800,10 @@ async def edit( rtc_region: VoiceRegion | None = ..., video_quality_mode: VideoQualityMode = ..., reason: str | None = ..., - ) -> StageChannel | None: - ... + ) -> StageChannel | None: ... @overload - async def edit(self) -> StageChannel | None: - ... + async def edit(self) -> StageChannel | None: ... async def edit(self, *, reason=None, **options): """|coro| @@ -2979,12 +2972,10 @@ async def edit( position: int = ..., overwrites: Mapping[Role | Member, PermissionOverwrite] = ..., reason: str | None = ..., - ) -> CategoryChannel | None: - ... + ) -> CategoryChannel | None: ... @overload - async def edit(self) -> CategoryChannel | None: - ... + async def edit(self) -> CategoryChannel | None: ... async def edit(self, *, reason=None, **options): """|coro| @@ -3552,8 +3543,7 @@ class VoiceChannelEffectAnimation(NamedTuple): type: VoiceChannelEffectAnimationType -class VoiceChannelSoundEffect(PartialSoundboardSound): - ... +class VoiceChannelSoundEffect(PartialSoundboardSound): ... class VoiceChannelEffectSendEvent: @@ -3656,9 +3646,9 @@ def _channel_factory(channel_type: int): def _threaded_channel_factory(channel_type: int): cls, value = _channel_factory(channel_type) if value in ( - ChannelType.private_thread, - ChannelType.public_thread, - ChannelType.news_thread, + ChannelType.private_thread, + ChannelType.public_thread, + ChannelType.news_thread, ): return Thread, value return cls, value @@ -3667,9 +3657,9 @@ def _threaded_channel_factory(channel_type: int): def _threaded_guild_channel_factory(channel_type: int): cls, value = _guild_channel_factory(channel_type) if value in ( - ChannelType.private_thread, - ChannelType.public_thread, - ChannelType.news_thread, + ChannelType.private_thread, + ChannelType.public_thread, + ChannelType.news_thread, ): return Thread, value return cls, value diff --git a/discord/gateway.py b/discord/gateway.py index 6f4ffb27e9..3b76a4c3fd 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -775,13 +775,19 @@ 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]): + 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} + "d": {"guild_id": guild_id, "fields": fields}, } - _log.debug("Requesting channel info for guild %s with fields %s.", guild_id, ", ".join(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: diff --git a/discord/raw_models.py b/discord/raw_models.py index 12c979e20d..0cd167cf0b 100644 --- a/discord/raw_models.py +++ b/discord/raw_models.py @@ -52,8 +52,8 @@ from .types.channel import VoiceChannelEffectSendEvent as VoiceChannelEffectSend from .types.member import MemberUpdateEvent from .types.raw_models import ( - AuditLogEntryEvent, VoiceChannelStartTimeUpdateEvent, -) + AuditLogEntryEvent, + ) from .types.raw_models import AutoModActionExecutionEvent as AutoModActionExecution from .types.raw_models import ( BulkMessageDeleteEvent, @@ -70,6 +70,7 @@ ThreadMembersUpdateEvent, ThreadUpdateEvent, TypingEvent, + VoiceChannelStartTimeUpdateEvent, VoiceChannelStatusUpdateEvent, VoiceServerUpdateEvent, VoiceStateEvent, @@ -510,7 +511,11 @@ class RawVoiceChannelStartTimeUpdateEvent(_RawReprMixin): 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.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 @@ -1073,6 +1078,7 @@ class ChannelInfo(_RawReprMixin): 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) diff --git a/discord/state.py b/discord/state.py index 640ab350be..1a7ece86a2 100644 --- a/discord/state.py +++ b/discord/state.py @@ -31,10 +31,9 @@ import itertools import logging import os -from asyncio import Future from collections import OrderedDict, deque from typing import ( - overload, TYPE_CHECKING, + TYPE_CHECKING, Any, Callable, Coroutine, @@ -42,6 +41,7 @@ Sequence, TypeVar, Union, + overload, ) from . import utils @@ -174,9 +174,15 @@ def parse_response(self, channel_info: list[ChannelInfo]) -> None: 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: + 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) + 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 @@ -248,7 +254,7 @@ def __init__( allowed_mentions = options.get("allowed_mentions") if allowed_mentions is not None and not isinstance( - allowed_mentions, AllowedMentions + allowed_mentions, AllowedMentions ): raise TypeError("allowed_mentions parameter must be AllowedMentions") @@ -373,7 +379,9 @@ 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: + 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: @@ -615,9 +623,9 @@ def _add_guild_from_data(self, data: GuildPayload) -> Guild: def _guild_needs_chunking(self, guild: Guild) -> bool: # If presences are enabled then we get back the old guild.large behaviour return ( - self._chunk_guilds - and not guild.chunked - and not (self._intents.presences and not guild.large) + self._chunk_guilds + and not guild.chunked + and not (self._intents.presences and not guild.large) ) def _get_guild_channel( @@ -707,16 +715,22 @@ async def _delay_ready(self) -> None: except asyncio.TimeoutError: break else: - if (needs_chunk := self._guild_needs_chunking(guild)) or self._request_channel_info: + 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) + 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) + 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) @@ -847,10 +861,10 @@ def parse_message_create(self, data) -> None: self._messages.append(message) # we ensure that the channel is either a TextChannel, VoiceChannel, StageChannel, or Thread if channel and channel.__class__ in ( - TextChannel, - VoiceChannel, - StageChannel, - Thread, + TextChannel, + VoiceChannel, + StageChannel, + Thread, ): channel.last_message_id = message.id # type: ignore @@ -1535,22 +1549,31 @@ async def chunk_guild(self, guild, *, wait=True, cache=None): return request.get_future() @overload - async def request_guild_channel_info(self, guild: Guild, *, wait: bool = True, cache: bool | None = None) -> list[ChannelInfo]: - ... + 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 = 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]: + 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, + guild.id, + self.loop, + self._get_guild, + cache=cache, ) ws = self._get_websocket(guild.id) # This is ignored upstream - await ws.request_channel_info(guild.id, fields=["status", "voice_start_time"]) + await ws.request_channel_info( + guild.id, fields=["status", "voice_start_time"] + ) if wait: return await request.wait() @@ -1750,7 +1773,11 @@ def parse_guild_members_chunk(self, data) -> None: 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) + _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: @@ -2020,9 +2047,9 @@ def parse_voice_state_update(self, data) -> None: if member is not None: if flags.voice: if ( - channel_id is None - and flags._voice_only - and member.id != self_id + channel_id is None + and flags._voice_only + and member.id != self_id ): # Only remove from cache if we only have the voice flag enabled # Member doesn't meet the Snowflake protocol currently @@ -2091,7 +2118,10 @@ def parse_voice_channel_start_time_update(self, data) -> 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 + "voice_channel_start_time_update", + channel, + old_voice_start_time, + channel.voice_start_time, ) else: _log.debug( From 22f00e68c6cbbcd46984b5c97637d078697fef7c Mon Sep 17 00:00:00 2001 From: plun1331 Date: Mon, 20 Apr 2026 16:19:27 -0700 Subject: [PATCH 03/15] fix: remove debug print --- discord/channel.py | 1 - 1 file changed, 1 deletion(-) diff --git a/discord/channel.py b/discord/channel.py index d8aef8c29b..c398108844 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -1659,7 +1659,6 @@ def _update( self._fill_overwrites(data) def _update_status(self, *, status: str | None = MISSING, voice_start_time: int | None = MISSING): - print(f"Updating status for {self.id}", status, voice_start_time) if status is not MISSING: self.status = status if voice_start_time is not MISSING: From 262faf02e62e38773c11d3666d7955f8071e54b2 Mon Sep 17 00:00:00 2001 From: plun1331 Date: Mon, 20 Apr 2026 16:22:04 -0700 Subject: [PATCH 04/15] chore: update changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79b8942837..8346d26848 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 From 6a0a3da0a5c64c902e2f78743fc2f521ac116982 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:22:34 +0000 Subject: [PATCH 05/15] style(pre-commit): auto fixes from pre-commit.com hooks --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8346d26848..2fec184c62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,10 +14,10 @@ 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`. +- 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 From be20f9c9d6dcf63043841bd7362c91a3f7cbf375 Mon Sep 17 00:00:00 2001 From: plun1331 Date: Mon, 20 Apr 2026 16:30:36 -0700 Subject: [PATCH 06/15] docs: add permission note --- discord/channel.py | 1 + 1 file changed, 1 insertion(+) diff --git a/discord/channel.py b/discord/channel.py index ca519322ed..27683c2367 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -2246,6 +2246,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 ---------- From 439f19e502f14a523871235de7ddab6c11621a44 Mon Sep 17 00:00:00 2001 From: plun1331 Date: Mon, 20 Apr 2026 16:31:29 -0700 Subject: [PATCH 07/15] docs: ChannelInfo --- docs/api/models.rst | 5 +++++ 1 file changed, 5 insertions(+) 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 From 636bc8f68cbdf45f146c233091eee3ce81ae98b9 Mon Sep 17 00:00:00 2001 From: plun1331 Date: Mon, 20 Apr 2026 16:33:16 -0700 Subject: [PATCH 08/15] fix: add ChannelInfo to all --- discord/raw_models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/discord/raw_models.py b/discord/raw_models.py index 0cd167cf0b..03088887e5 100644 --- a/discord/raw_models.py +++ b/discord/raw_models.py @@ -101,6 +101,7 @@ "RawVoiceStateUpdateEvent", "RawVoiceChannelStartTimeUpdateEvent", "RawMemberUpdateEvent", + "ChannelInfo", ) From 85b5934e4047e8ca5389a32e14763649154dc700 Mon Sep 17 00:00:00 2001 From: plun1331 Date: Mon, 20 Apr 2026 16:45:33 -0700 Subject: [PATCH 09/15] fix: target for 2.9 --- discord/channel.py | 3 +++ discord/client.py | 2 +- discord/guild.py | 2 +- discord/raw_models.py | 62 +++++++++++++++++++++---------------------- 4 files changed, 36 insertions(+), 33 deletions(-) diff --git a/discord/channel.py b/discord/channel.py index 27683c2367..d0e9ae0561 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -1663,6 +1663,9 @@ def _update_status( @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) diff --git a/discord/client.py b/discord/client.py index 84b4b18bd2..0a1f5cd281 100644 --- a/discord/client.py +++ b/discord/client.py @@ -236,7 +236,7 @@ class Client: cache_channel_info: :class:`bool` Whether to automatically request and cache channel statuses on startup. Defaults to ``False``. - .. versionadded:: 2.8 + .. versionadded:: 2.9 Attributes ----------- diff --git a/discord/guild.py b/discord/guild.py index e2cd5dee5e..6bf4518a64 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -3960,7 +3960,7 @@ async def channel_info(self, *, cache: bool = True) -> None | list[ChannelInfo]: Requests all channel statuses for this guild over the websocket. - .. versionadded:: 1.5 + .. versionadded:: 2.9 Parameters ---------- diff --git a/discord/raw_models.py b/discord/raw_models.py index 03088887e5..7b02007d55 100644 --- a/discord/raw_models.py +++ b/discord/raw_models.py @@ -490,36 +490,6 @@ def __init__(self, data: VoiceChannelStatusUpdateEvent) -> None: self.data: VoiceChannelStatusUpdateEvent = data -class RawVoiceChannelStartTimeUpdateEvent(_RawReprMixin): - """Represents the payload for an :func:`on_raw_voice_channel_start_time_update` event. - - .. versionadded:: 2.5 - - 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 RawTypingEvent(_RawReprMixin): """Represents the payload for a :func:`on_raw_typing` event. @@ -1065,10 +1035,40 @@ def __init__(self, data: MemberUpdateEvent, member: Member) -> 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.8 + .. versionadded:: 2.9 Attributes ---------- From de0f644348ce817702671d2e18fc81ef158bb86d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:46:10 +0000 Subject: [PATCH 10/15] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/channel.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/discord/channel.py b/discord/channel.py index d0e9ae0561..7d7c5210b9 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -1665,7 +1665,8 @@ def _update_status( def voice_start_time(self) -> datetime.datetime | None: """:class:`datetime.datetime` | :class:`None`: The time that the voice session started. - .. versionadded:: 2.9""" + .. versionadded:: 2.9 + """ if self._voice_start_time is None: return None return datetime.datetime.fromtimestamp(self._voice_start_time, tz=datetime.UTC) From 972996eb2be03ef9b0146578b60253dd59c6c17d Mon Sep 17 00:00:00 2001 From: plun1331 Date: Mon, 20 Apr 2026 16:49:00 -0700 Subject: [PATCH 11/15] docs: events --- docs/api/events.rst | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) 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) From 9e981600832a2ac4195d1d31c293ebdfd2d68394 Mon Sep 17 00:00:00 2001 From: plun1331 Date: Wed, 22 Apr 2026 13:07:58 -0700 Subject: [PATCH 12/15] Apply suggestions from code review Co-authored-by: Paillat Signed-off-by: plun1331 --- discord/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/client.py b/discord/client.py index 0a1f5cd281..47132db03a 100644 --- a/discord/client.py +++ b/discord/client.py @@ -234,7 +234,7 @@ class Client: .. versionadded:: 2.8 cache_channel_info: :class:`bool` - Whether to automatically request and cache channel statuses on startup. Defaults to ``False``. + Whether to automatically request and cache voice channel statuses on startup. Defaults to ``False``. .. versionadded:: 2.9 From cf0ebf3ddde76754b5a1ada6c638c657059953fa Mon Sep 17 00:00:00 2001 From: plun1331 Date: Sat, 2 May 2026 11:51:15 -0700 Subject: [PATCH 13/15] Apply suggestions from code review Co-authored-by: plun1331 Signed-off-by: plun1331 --- discord/guild.py | 6 +----- discord/state.py | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/discord/guild.py b/discord/guild.py index 6bf4518a64..ca8873edf2 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -3955,7 +3955,7 @@ 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 channel_info(self, *, cache: bool = True) -> None | list[ChannelInfo]: + async def request_channel_info(self, *, cache: bool = True) -> None | list[ChannelInfo]: """|coro| Requests all channel statuses for this guild over the websocket. @@ -3967,10 +3967,6 @@ async def channel_info(self, *, cache: bool = True) -> None | list[ChannelInfo]: cache: :class:`bool` Whether to cache the channel statuses as well. - Raises - ------ - ClientException - The members intent is not enabled. """ if not self._state.is_guild_evicted(self): diff --git a/discord/state.py b/discord/state.py index 1a7ece86a2..27f1f57953 100644 --- a/discord/state.py +++ b/discord/state.py @@ -1570,7 +1570,7 @@ async def request_guild_channel_info( self._get_guild, cache=cache, ) - ws = self._get_websocket(guild.id) # This is ignored upstream + ws = self._get_websocket(guild.id) await ws.request_channel_info( guild.id, fields=["status", "voice_start_time"] ) From b862ca3c8b7872ac74c01eff2afd50de879ba27f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 2 May 2026 18:51:42 +0000 Subject: [PATCH 14/15] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/guild.py | 5 +++-- discord/state.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/discord/guild.py b/discord/guild.py index ca8873edf2..441c597720 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -3955,7 +3955,9 @@ 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]: + async def request_channel_info( + self, *, cache: bool = True + ) -> None | list[ChannelInfo]: """|coro| Requests all channel statuses for this guild over the websocket. @@ -3966,7 +3968,6 @@ async def request_channel_info(self, *, cache: bool = True) -> None | list[Chann ---------- cache: :class:`bool` Whether to cache the channel statuses as well. - """ if not self._state.is_guild_evicted(self): diff --git a/discord/state.py b/discord/state.py index 27f1f57953..99f76d76ef 100644 --- a/discord/state.py +++ b/discord/state.py @@ -1570,7 +1570,7 @@ async def request_guild_channel_info( self._get_guild, cache=cache, ) - ws = self._get_websocket(guild.id) + ws = self._get_websocket(guild.id) await ws.request_channel_info( guild.id, fields=["status", "voice_start_time"] ) From b960f0d4a017e32f7c6ba8be32ec78738e055d0a Mon Sep 17 00:00:00 2001 From: plun1331 Date: Wed, 20 May 2026 03:51:00 -0700 Subject: [PATCH 15/15] Apply suggestions from code review Co-authored-by: Michael Signed-off-by: plun1331 --- discord/raw_models.py | 4 ++-- discord/state.py | 6 +++--- docs/api/events.rst | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/discord/raw_models.py b/discord/raw_models.py index 7b02007d55..67d0ce949d 100644 --- a/discord/raw_models.py +++ b/discord/raw_models.py @@ -1082,5 +1082,5 @@ class ChannelInfo(_RawReprMixin): 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) + self.status: str | None = data.get("status") + self.voice_start_time: int | None = data.get("voice_start_time") diff --git a/discord/state.py b/discord/state.py index 99f76d76ef..e5d00cdf7e 100644 --- a/discord/state.py +++ b/discord/state.py @@ -1232,7 +1232,7 @@ def parse_thread_create(self, data) -> None: "join_timestamp": data["thread_metadata"][ "create_timestamp" ], - "flags": utils.utils.MISSING, + "flags": utils.MISSING, }, ) ) @@ -1561,7 +1561,7 @@ async def request_guild_channel_info( 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 + cache = cache if cache is not None else self.cache_channel_info request = self._channel_info_requests.get(guild.id) if request is None: self._channel_info_requests[guild.id] = request = ChannelInfoRequest( @@ -2108,7 +2108,7 @@ def parse_voice_channel_status_update(self, data) -> None: ) def parse_voice_channel_start_time_update(self, data) -> None: - raw = RawVoiceChannelStatusUpdateEvent(data) + raw = RawVoiceChannelStartTimeUpdateEvent(data) self.dispatch("raw_voice_channel_start_time_update", raw) guild = self._get_guild(int(data["guild_id"])) channel_id = int(data["id"]) diff --git a/docs/api/events.rst b/docs/api/events.rst index bf9f3f60ee..a18fb717a8 100644 --- a/docs/api/events.rst +++ b/docs/api/events.rst @@ -1422,7 +1422,7 @@ Voice Channel Status Update .. versionadded:: 2.9 :param channel: The channel where the voice channel start time update originated from. - :type channel: :class:`abc.GuildChannel` + :type channel: :class:`VoiceChannel` | :class:`StageChannel` :param before: The old voice channel start time. :type before: Optional[:class:`datetime.datetime`] :param after: The new voice channel start time.