diff --git a/CHANGELOG.md b/CHANGELOG.md index 5173e5795c..70ed100b76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ These changes are available on the `master` branch, but have not yet been releas ### Added +- Added `ScheduledEventEntityType` enum and `ScheduledEventEntityMetadata` class, added + missing attributes and API-aligned parameters to `ScheduledEvent`, + `Guild.create_scheduled_event`, and `ScheduledEvent.edit`, and added `use_cache` to + `ScheduledEvent.subscribers()`. + ([#3025](https://github.com/Pycord-Development/pycord/pull/3025)) + ### Changed ### Fixed @@ -22,9 +28,15 @@ These changes are available on the `master` branch, but have not yet been releas ([#3231](https://github.com/Pycord-Development/pycord/pull/3231)) - Allow `ForumTag` to be created without an emoji. ([#3245](https://github.com/Pycord-Development/pycord/pull/3245)) +- Fixed `ScheduledEvent` subscriber cache not being kept in sync correctly. + ([#3025](https://github.com/Pycord-Development/pycord/pull/3025)) ### Deprecated +- Deprecated `ScheduledEventLocationType`, `ScheduledEventLocation`, and several + `ScheduledEvent` attributes and related methods' parameters in favor of their + API-aligned names. ([#3025](https://github.com/Pycord-Development/pycord/pull/3025)) + ### Removed ## [2.8.0] - 2026-05-18 diff --git a/discord/audit_logs.py b/discord/audit_logs.py index b2f6a72393..6eae0135b0 100644 --- a/discord/audit_logs.py +++ b/discord/audit_logs.py @@ -277,10 +277,7 @@ class AuditLogChanges: "format_type": (None, _enum_transformer(enums.StickerFormatType)), "type": (None, _transform_type), "status": (None, _enum_transformer(enums.ScheduledEventStatus)), - "entity_type": ( - "location_type", - _enum_transformer(enums.ScheduledEventLocationType), - ), + "entity_type": (None, _enum_transformer(enums.ScheduledEventEntityType)), "command_id": ("command_id", _transform_snowflake), "image_hash": ("image", _transform_scheduled_event_image), "trigger_type": (None, _enum_transformer(enums.AutoModTriggerType)), @@ -318,7 +315,11 @@ def __init__( "$add_allow_list", ]: self._handle_trigger_metadata( - self.before, self.after, entry, elem["new_value"], attr # type: ignore + self.before, + self.after, + entry, + elem["new_value"], + attr, # type: ignore ) continue elif attr in [ @@ -327,7 +328,11 @@ def __init__( "$remove_allow_list", ]: self._handle_trigger_metadata( - self.after, self.before, entry, elem["new_value"], attr # type: ignore + self.after, + self.before, + entry, + elem["new_value"], + attr, # type: ignore ) continue @@ -349,21 +354,6 @@ def __init__( if transformer: before = transformer(entry, before) - if attr == "location" and hasattr(self.before, "location_type"): - from .scheduled_events import ScheduledEventLocation - - if ( - self.before.location_type - is enums.ScheduledEventLocationType.external - ): - before = ScheduledEventLocation(state=state, value=before) - elif hasattr(self.before, "channel"): - before = ScheduledEventLocation( - state=state, value=self.before.channel - ) - - setattr(self.before, attr, before) - try: after = elem["new_value"] except KeyError: @@ -372,22 +362,34 @@ def __init__( if transformer: after = transformer(entry, after) - if attr == "location" and hasattr(self.after, "location_type"): - from .scheduled_events import ScheduledEventLocation - - if ( - self.after.location_type - is enums.ScheduledEventLocationType.external - ): - after = ScheduledEventLocation(state=state, value=after) - elif hasattr(self.after, "channel"): - after = ScheduledEventLocation( - state=state, value=self.after.channel - ) - + setattr(self.before, attr, before) setattr(self.after, attr, after) + if attr == "location": + from .scheduled_events import ScheduledEventEntityMetadata + + setattr( + self.after, + "entity_metadata", + ( + ScheduledEventEntityMetadata(location=after) + if after is not None + else None + ), + ) + setattr( + self.before, + "entity_metadata", + ( + ScheduledEventEntityMetadata(location=before) + if before is not None + else None + ), + ) # add an alias + if hasattr(self.after, "entity_type"): + self.after.location_type = self.after.entity_type + self.before.location_type = self.before.entity_type if hasattr(self.after, "colour"): self.after.color = self.after.colour self.before.color = self.before.colour @@ -691,7 +693,12 @@ def _convert_target_invite(self, target_id: int) -> Invite: "uses": changeset.uses, } - obj = Invite(state=self._state, data=fake_payload, guild=self.guild, channel=changeset.channel) # type: ignore + obj = Invite( + state=self._state, + data=fake_payload, + guild=self.guild, + channel=changeset.channel, + ) # type: ignore try: obj.inviter = changeset.inviter except AttributeError: diff --git a/discord/enums.py b/discord/enums.py index 802bb41535..ff8e86c135 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -30,6 +30,8 @@ from enum import IntEnum from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, Union +from typing_extensions import deprecated + __all__ = ( "Enum", "ChannelType", @@ -62,6 +64,7 @@ "EmbeddedActivity", "ScheduledEventStatus", "ScheduledEventPrivacyLevel", + "ScheduledEventEntityType", "ScheduledEventLocationType", "InputTextStyle", "SlashCommandOptionType", @@ -96,21 +99,17 @@ def _create_value_cls(name, comparable): cls.__repr__ = lambda self: f"<{name}.{self.name}: {self.value!r}>" cls.__str__ = lambda self: f"{name}.{self.name}" if comparable: - cls.__le__ = ( - lambda self, other: isinstance(other, self.__class__) - and self.value <= other.value + cls.__le__ = lambda self, other: ( + isinstance(other, self.__class__) and self.value <= other.value ) - cls.__ge__ = ( - lambda self, other: isinstance(other, self.__class__) - and self.value >= other.value + cls.__ge__ = lambda self, other: ( + isinstance(other, self.__class__) and self.value >= other.value ) - cls.__lt__ = ( - lambda self, other: isinstance(other, self.__class__) - and self.value < other.value + cls.__lt__ = lambda self, other: ( + isinstance(other, self.__class__) and self.value < other.value ) - cls.__gt__ = ( - lambda self, other: isinstance(other, self.__class__) - and self.value > other.value + cls.__gt__ = lambda self, other: ( + isinstance(other, self.__class__) and self.value > other.value ) return cls @@ -970,13 +969,20 @@ def __int__(self): return self.value -class ScheduledEventLocationType(Enum): - """Scheduled event location type""" +class ScheduledEventEntityType(Enum): + """Scheduled event entity type""" stage_instance = 1 voice = 2 external = 3 + def __int__(self): + return self.value + + +# TODO(Paillat-dev): Add @deprecated notice using warnings.deprecated or in some other way +ScheduledEventLocationType = ScheduledEventEntityType + class AutoModTriggerType(Enum): """Automod trigger type""" diff --git a/discord/guild.py b/discord/guild.py index 4ecbfe57bd..e1899b8fe7 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -62,7 +62,7 @@ NSFWLevel, OnboardingMode, RoleType, - ScheduledEventLocationType, + ScheduledEventEntityType, ScheduledEventPrivacyLevel, SortOrder, VerificationLevel, @@ -70,7 +70,13 @@ VoiceRegion, try_enum, ) -from .errors import ClientException, HTTPException, InvalidArgument, InvalidData +from .errors import ( + ClientException, + HTTPException, + InvalidArgument, + InvalidData, + ValidationError, +) from .file import File from .flags import SystemChannelFlags from .incidents import IncidentsData @@ -88,13 +94,17 @@ from .onboarding import Onboarding from .permissions import PermissionOverwrite from .role import Role, RoleColours -from .scheduled_events import ScheduledEvent, ScheduledEventLocation +from .scheduled_events import ( + ScheduledEvent, + ScheduledEventEntityMetadata, + ScheduledEventLocation, +) from .soundboard import SoundboardSound from .stage_instance import StageInstance from .sticker import GuildSticker from .threads import Thread, ThreadMember from .user import User -from .utils import _D, _FETCHABLE +from .utils import _D, _FETCHABLE, warn_deprecated from .welcome_screen import WelcomeScreen, WelcomeScreenChannel from .widget import Widget @@ -4277,6 +4287,7 @@ def get_scheduled_event(self, event_id: int, /) -> ScheduledEvent | None: """ return self._scheduled_events.get(event_id) + @overload async def create_scheduled_event( self, *, @@ -4284,30 +4295,76 @@ async def create_scheduled_event( description: str = MISSING, start_time: datetime.datetime, end_time: datetime.datetime = MISSING, - location: str | int | VoiceChannel | StageChannel | ScheduledEventLocation, + location: ( + str | int | VoiceChannel | StageChannel | ScheduledEventLocation + ) = MISSING, privacy_level: ScheduledEventPrivacyLevel = ScheduledEventPrivacyLevel.guild_only, reason: str | None = None, image: bytes = MISSING, + ) -> ScheduledEvent | None: ... + + @overload + async def create_scheduled_event( + self, + *, + name: str, + description: str = MISSING, + scheduled_start_time: datetime.datetime, + scheduled_end_time: datetime.datetime = MISSING, + entity_type: ScheduledEventEntityType = MISSING, + entity_metadata: ScheduledEventEntityMetadata | None = MISSING, + channel_id: int | VoiceChannel | StageChannel = MISSING, + privacy_level: ScheduledEventPrivacyLevel = MISSING, + reason: str | None = None, + image: bytes = MISSING, + ) -> ScheduledEvent | None: ... + + async def create_scheduled_event( + self, + *, + name: str, + description: str = MISSING, + scheduled_start_time: datetime.datetime = MISSING, + scheduled_end_time: datetime.datetime = MISSING, + location: ( + str | int | VoiceChannel | StageChannel | ScheduledEventLocation + ) = MISSING, + entity_type: ScheduledEventEntityType = MISSING, + entity_metadata: ScheduledEventEntityMetadata | None = MISSING, + channel_id: int | VoiceChannel | StageChannel = MISSING, + privacy_level: ScheduledEventPrivacyLevel = MISSING, + reason: str | None = None, + image: bytes = MISSING, + start_time: datetime.datetime = MISSING, + end_time: datetime.datetime = MISSING, ) -> ScheduledEvent | None: """|coro| Creates a scheduled event. + For EXTERNAL events, ``entity_metadata`` with a location and ``end_time`` are required. + For STAGE_INSTANCE or VOICE events, ``channel_id`` is required. + Parameters ---------- name: :class:`str` The name of the scheduled event. description: Optional[:class:`str`] The description of the scheduled event. - start_time: :class:`datetime.datetime` + scheduled_start_time: :class:`datetime.datetime` A datetime object of when the scheduled event is supposed to start. - end_time: Optional[:class:`datetime.datetime`] + scheduled_end_time: Optional[:class:`datetime.datetime`] A datetime object of when the scheduled event is supposed to end. - location: :class:`ScheduledEventLocation` - The location of where the event is happening. + Required for EXTERNAL events. + entity_type: :class:`ScheduledEventEntityType` + The type of scheduled event (STAGE_INSTANCE, VOICE, or EXTERNAL). + entity_metadata: Optional[:class:`ScheduledEventEntityMetadata`] + The entity metadata (required for EXTERNAL events with a location). + channel_id: Optional[Union[:class:`int`, :class:`VoiceChannel`, :class:`StageChannel`]] + The channel ID for STAGE_INSTANCE or VOICE events. + Can be a channel object or a snowflake ID. privacy_level: :class:`ScheduledEventPrivacyLevel` The privacy level of the event. Currently, the only possible value - is :attr:`ScheduledEventPrivacyLevel.guild_only`, which is default, - so there is no need to change this parameter. + is :attr:`ScheduledEventPrivacyLevel.guild_only`, which is default. reason: Optional[:class:`str`] The reason to show in the audit log. image: Optional[:class:`bytes`] @@ -4324,34 +4381,96 @@ async def create_scheduled_event( You do not have the Manage Events permission. HTTPException The operation failed. + ValidationError + Invalid parameters for the event type. """ - payload: dict[str, str | int] = { - "name": name, - "scheduled_start_time": start_time.isoformat(), - "privacy_level": int(privacy_level), - } + if location is MISSING and entity_type is MISSING: + raise TypeError("Either location or entity_type must be provided.") + if start_time is MISSING and scheduled_start_time is MISSING: + raise TypeError( + "Either start_time or scheduled_start_time must be provided." + ) + if start_time is not MISSING: + warn_deprecated("start_time", "scheduled_start_time", "2.9", "3.0") + if scheduled_start_time is MISSING: + scheduled_start_time = start_time - if not isinstance(location, ScheduledEventLocation): - location = ScheduledEventLocation(state=self._state, value=location) + if end_time is not MISSING: + warn_deprecated("end_time", "scheduled_end_time", "2.9", "3.0") + if scheduled_end_time is MISSING: + scheduled_end_time = end_time + + if location is not MISSING: + warn_deprecated("location", "entity_metadata", "2.9", "3.0") + if entity_metadata is MISSING: + if not isinstance(location, ScheduledEventLocation): + location = ScheduledEventLocation( + state=self._state, + value=location, + _suppress_deprecation=True, + ) + if entity_type is MISSING: + entity_type = location.type + if location.type == ScheduledEventEntityType.external: + entity_metadata = ScheduledEventEntityMetadata(str(location)) + else: + channel_id = location.value.id - payload["entity_type"] = location.type.value + if entity_type is MISSING: + raise TypeError( + "entity_type could not be resolved. Pass entity_type explicitly " + "or provide a location with a resolvable type." + ) - if location.type == ScheduledEventLocationType.external: - payload["channel_id"] = None - payload["entity_metadata"] = {"location": location.value} + if privacy_level is not MISSING: + warn_deprecated("privacy_level", since="2.9") + resolved_privacy_level = privacy_level else: - payload["channel_id"] = location.value.id - payload["entity_metadata"] = None + resolved_privacy_level = ScheduledEventPrivacyLevel.guild_only + + if channel_id is not MISSING and not isinstance(channel_id, int): + channel_id = channel_id.id + + payload: dict[str, str | int | None] = { + "name": name, + "scheduled_start_time": scheduled_start_time.isoformat(), + "entity_type": int(entity_type), + "privacy_level": int(resolved_privacy_level), + } + + if scheduled_end_time is not MISSING: + payload["scheduled_end_time"] = scheduled_end_time.isoformat() if description is not MISSING: payload["description"] = description - if end_time is not MISSING: - payload["scheduled_end_time"] = end_time.isoformat() - if image is not MISSING: payload["image"] = utils._bytes_to_base64_data(image) + if entity_type == ScheduledEventEntityType.external: + if entity_metadata is MISSING or entity_metadata is None: + raise ValidationError( + "entity_metadata with a location is required for external events." + ) + if not entity_metadata.location: + raise ValidationError( + "entity_metadata.location cannot be empty for external events." + ) + if scheduled_end_time is MISSING: + raise ValidationError( + "scheduled_end_time is required for external events." + ) + + payload["channel_id"] = None + payload["entity_metadata"] = entity_metadata.to_payload() + else: + if channel_id is MISSING: + raise ValidationError( + "channel_id is required for stage_instance and voice events." + ) + + payload["channel_id"] = channel_id + data = await self._state.http.create_scheduled_event( guild_id=self.id, reason=reason, **payload ) diff --git a/discord/iterators.py b/discord/iterators.py index 7813f812e9..33216f1faf 100644 --- a/discord/iterators.py +++ b/discord/iterators.py @@ -27,6 +27,7 @@ import asyncio import datetime +import warnings from typing import ( TYPE_CHECKING, Any, @@ -901,6 +902,7 @@ def __init__( with_member: bool = False, before: datetime.datetime | int | None = None, after: datetime.datetime | int | None = None, + use_cache: bool = False, ): if isinstance(before, datetime.datetime): before = Object(id=time_snowflake(before, high=False)) @@ -912,6 +914,14 @@ def __init__( self.with_member = with_member self.before = before self.after = after + self.use_cache = use_cache + + if use_cache and not with_member: + warnings.warn( + "use_cache=True only yields cached members; as_member=False is ignored.", + UserWarning, + stacklevel=2, + ) self.subscribers = asyncio.Queue() self.get_subscribers = self.event._state.http.get_scheduled_event_users @@ -951,12 +961,33 @@ def user_from_payload(self, data): return User(state=self.event._state, data=user) + async def _fill_from_cache(self): + """Fill subscribers queue from cached user IDs.""" + cached_user_ids = list(self.event._cached_subscribers) + remaining = self.limit + + for user_id in cached_user_ids: + if remaining is not None and remaining <= 0: + break + member = self.event.guild.get_member(user_id) + if member: + await self.subscribers.put(member) + if remaining is not None: + remaining -= 1 + + self.limit = 0 + async def fill_subs(self): if not self._get_retrieve(): return + if self.use_cache: + await self._fill_from_cache() + return + before = self.before.id if self.before else None after = self.after.id if self.after else None + data = await self.get_subscribers( guild_id=self.event.guild.id, event_id=self.event.id, @@ -969,9 +1000,8 @@ async def fill_subs(self): data_length = len(data) if data_length < self.retrieve: self.limit = 0 - elif data_length > 0: - if self.limit: - self.limit -= self.retrieve + elif data_length > 0 and self.limit is not None: + self.limit -= self.retrieve self.after = Object(id=int(data[-1]["user_id"])) for element in reversed(data): diff --git a/discord/scheduled_events.py b/discord/scheduled_events.py index 7e339dcee7..356b7c50b5 100644 --- a/discord/scheduled_events.py +++ b/discord/scheduled_events.py @@ -27,15 +27,17 @@ import datetime from typing import TYPE_CHECKING, Any +import typing_extensions + from . import utils from .asset import Asset from .enums import ( - ScheduledEventLocationType, + ScheduledEventEntityType, ScheduledEventPrivacyLevel, ScheduledEventStatus, try_enum, ) -from .errors import InvalidArgument, ValidationError +from .errors import ValidationError from .iterators import ScheduledEventSubscribersIterator from .mixins import Hashable from .object import Object @@ -44,17 +46,18 @@ __all__ = ( "ScheduledEvent", "ScheduledEventLocation", + "ScheduledEventEntityMetadata", ) if TYPE_CHECKING: from .abc import Snowflake + from .channel import StageChannel, VoiceChannel from .guild import Guild - from .iterators import AsyncIterator from .member import Member from .state import ConnectionState - from .types.channel import StageChannel, VoiceChannel from .types.scheduled_events import ScheduledEvent as ScheduledEventPayload + MISSING = utils.MISSING @@ -71,13 +74,16 @@ class ScheduledEventLocation: | :class:`str` | :attr:`ScheduledEventLocationType.external` | +------------------------+---------------------------------------------------+ + .. deprecated:: 2.9 + Use :class:`ScheduledEventEntityMetadata` instead. + .. versionadded:: 2.0 Attributes ---------- value: Union[:class:`str`, :class:`StageChannel`, :class:`VoiceChannel`, :class:`Object`] The actual location of the scheduled event. - type: :class:`ScheduledEventLocationType` + type: :class:`ScheduledEventEntityType` The type of location. """ @@ -89,13 +95,24 @@ class ScheduledEventLocation: def __init__( self, *, - state: ConnectionState, - value: str | int | StageChannel | VoiceChannel, - ): - self._state = state - self.value: str | StageChannel | VoiceChannel | Object - if isinstance(value, int): - self.value = self._state.get_channel(id=int(value)) or Object(id=int(value)) + state: ConnectionState | None = None, + value: str | int | StageChannel | VoiceChannel | None = None, + _suppress_deprecation: bool = False, + ) -> None: + if not _suppress_deprecation: + warn_deprecated( + "ScheduledEventLocation", "ScheduledEventEntityMetadata", "2.9" + ) + self._state: ConnectionState | None = state + self.value: str | StageChannel | VoiceChannel | Object | None + if value is None: + self.value = None + elif isinstance(value, int): + self.value = ( + self._state.get_channel(id=int(value)) or Object(id=int(value)) + if self._state + else Object(id=int(value)) + ) else: self.value = value @@ -103,16 +120,57 @@ def __repr__(self) -> str: return f"" def __str__(self) -> str: - return str(self.value) + return str(self.value) if self.value else "" @property - def type(self) -> ScheduledEventLocationType: + def type(self) -> ScheduledEventEntityType: + """The type of location.""" if isinstance(self.value, str): - return ScheduledEventLocationType.external + return ScheduledEventEntityType.external elif self.value.__class__.__name__ == "StageChannel": - return ScheduledEventLocationType.stage_instance + return ScheduledEventEntityType.stage_instance elif self.value.__class__.__name__ == "VoiceChannel": - return ScheduledEventLocationType.voice + return ScheduledEventEntityType.voice + return ScheduledEventEntityType.voice + + +class ScheduledEventEntityMetadata: + """Represents a scheduled event's entity metadata. + + This contains additional metadata for the scheduled event, particularly + for external events which require a location string. + + .. versionadded:: 2.9 + + Attributes + ---------- + location: Optional[:class:`str`] + The location of the event (1-100 characters). Only present for EXTERNAL events. + """ + + __slots__ = ("location",) + + def __init__( + self, + location: str | None = None, + ) -> None: + self.location: str | None = location + + def __repr__(self) -> str: + return f"" + + def __str__(self) -> str: + return self.location or "" + + def to_payload(self) -> dict[str, str | None]: + """Converts the entity metadata to a Discord API payload. + + Returns + ------- + dict[str, str | None] + A dictionary with the entity metadata fields for the API. + """ + return {"location": self.location} class ScheduledEvent(Hashable): @@ -146,16 +204,13 @@ class ScheduledEvent(Hashable): The name of the scheduled event. description: Optional[:class:`str`] The description of the scheduled event. - start_time: :class:`datetime.datetime` + scheduled_start_time: :class:`datetime.datetime` The time when the event will start - end_time: Optional[:class:`datetime.datetime`] + scheduled_end_time: Optional[:class:`datetime.datetime`] The time when the event is supposed to end. status: :class:`ScheduledEventStatus` The status of the scheduled event. - location: :class:`ScheduledEventLocation` - The location of the event. - See :class:`ScheduledEventLocation` for more information. - subscriber_count: Optional[:class:`int`] + user_count: :class:`int` The number of users that have marked themselves as interested in the event. creator_id: Optional[:class:`int`] The ID of the user who created the event. @@ -167,22 +222,33 @@ class ScheduledEvent(Hashable): The privacy level of the event. Currently, the only possible value is :attr:`ScheduledEventPrivacyLevel.guild_only`, which is default, so there is no need to use this attribute. + entity_type: :class:`ScheduledEventEntityType` + The type of scheduled event (STAGE_INSTANCE, VOICE, or EXTERNAL). + entity_id: Optional[:class:`int`] + The ID of an entity associated with the scheduled event. + entity_metadata: Optional[:class:`ScheduledEventEntityMetadata`] + Additional metadata for the scheduled event (e.g., location for EXTERNAL events). """ __slots__ = ( "id", "name", "description", - "start_time", - "end_time", + "scheduled_start_time", + "scheduled_end_time", "status", "creator_id", "creator", - "location", "guild", "_state", "_image", - "subscriber_count", + "user_count", + "_cached_subscribers", + "entity_type", + "privacy_level", + "channel_id", + "entity_id", + "entity_metadata", ) def __init__( @@ -200,28 +266,36 @@ def __init__( self.name: str = data.get("name") self.description: str | None = data.get("description", None) self._image: str | None = data.get("image", None) - self.start_time: datetime.datetime = datetime.datetime.fromisoformat( + self.scheduled_start_time: datetime.datetime = datetime.datetime.fromisoformat( data.get("scheduled_start_time") ) - if end_time := data.get("scheduled_end_time", None): - end_time = datetime.datetime.fromisoformat(end_time) - self.end_time: datetime.datetime | None = end_time + if scheduled_end_time := data.get("scheduled_end_time", None): + scheduled_end_time = datetime.datetime.fromisoformat(scheduled_end_time) + self.scheduled_end_time: datetime.datetime | None = scheduled_end_time self.status: ScheduledEventStatus = try_enum( ScheduledEventStatus, data.get("status") ) - self.subscriber_count: int | None = data.get("user_count", None) + self.entity_type: ScheduledEventEntityType = try_enum( + ScheduledEventEntityType, data.get("entity_type") + ) + self.privacy_level: ScheduledEventPrivacyLevel = try_enum( + ScheduledEventPrivacyLevel, data.get("privacy_level") + ) + self.channel_id: int | None = utils._get_as_snowflake(data, "channel_id") + self.entity_id: int | None = utils._get_as_snowflake(data, "entity_id") + + entity_metadata_data = data.get("entity_metadata") + self.entity_metadata: ScheduledEventEntityMetadata | None = ( + ScheduledEventEntityMetadata(location=entity_metadata_data.get("location")) + if entity_metadata_data + else None + ) + + self._cached_subscribers: set[int] = set() + self.user_count: int | None = data.get("user_count") self.creator_id: int | None = utils._get_as_snowflake(data, "creator_id") self.creator: Member | None = creator - entity_metadata = data.get("entity_metadata") - channel_id = data.get("channel_id", None) - if channel_id is None: - self.location = ScheduledEventLocation( - state=state, value=entity_metadata["location"] - ) - else: - self.location = ScheduledEventLocation(state=state, value=int(channel_id)) - def __str__(self) -> str: return self.name @@ -230,12 +304,31 @@ def __repr__(self) -> str: f"" + f"user_count={self.user_count} " + f"creator_id={self.creator_id} " + f"channel_id={self.channel_id}>" + ) + + @property + @typing_extensions.deprecated( + "location is deprecated since 2.9 and will be removed in 3.0, consider using entity_metadata instead", + ) + def location(self) -> ScheduledEventLocation | None: + """Returns the location of the event.""" + if self.channel_id is None: + if self.entity_metadata is None: + return None + return ScheduledEventLocation( + state=self._state, + value=self.entity_metadata.location, + _suppress_deprecation=True, + ) + return ScheduledEventLocation( + state=self._state, value=self.channel_id, _suppress_deprecation=True ) @property @@ -244,9 +337,51 @@ def created_at(self) -> datetime.datetime: return utils.snowflake_time(self.id) @property + @typing_extensions.deprecated( + "start_time is deprecated since 2.9 and will be removed in 3.0, consider using scheduled_start_time instead", + ) + def start_time(self) -> datetime.datetime: + """ + Returns the scheduled start time of the event. + + .. deprecated:: 2.9 + Use :attr:`scheduled_start_time` instead. + """ + return self.scheduled_start_time + + @property + @typing_extensions.deprecated( + "end_time is deprecated since 2.9 and will be removed in 3.0, consider using scheduled_end_time instead", + ) + def end_time(self) -> datetime.datetime | None: + """ + Returns the scheduled end time of the event. + + .. deprecated:: 2.9 + Use :attr:`scheduled_end_time` instead. + """ + return self.scheduled_end_time + + @property + @typing_extensions.deprecated( + "subscriber_count is deprecated since 2.9 and will be removed in 3.0, consider using user_count instead", + ) + def subscriber_count(self) -> int | None: + """ + Returns the number of users subscribed to the event. + + .. deprecated:: 2.9 + Use :attr:`user_count` instead. + """ + return self.user_count + + @property + @typing_extensions.deprecated( + "interested is deprecated since 2.9 and will be removed in 3.0, consider using user_count instead", + ) def interested(self) -> int | None: - """An alias to :attr:`.subscriber_count`""" - return self.subscriber_count + """An alias to :attr:`.user_count`""" + return self.user_count @property def url(self) -> str: @@ -281,55 +416,69 @@ async def edit( reason: str | None = None, name: str = MISSING, description: str = MISSING, - status: int | ScheduledEventStatus = MISSING, + status: ScheduledEventStatus = MISSING, location: ( str | int | VoiceChannel | StageChannel | ScheduledEventLocation ) = MISSING, + scheduled_start_time: datetime.datetime = MISSING, + scheduled_end_time: datetime.datetime = MISSING, + image: bytes | None = MISSING, + cover: bytes | None = MISSING, + privacy_level: ScheduledEventPrivacyLevel = MISSING, + entity_type: ScheduledEventEntityType = MISSING, + entity_metadata: ScheduledEventEntityMetadata | None = MISSING, start_time: datetime.datetime = MISSING, end_time: datetime.datetime = MISSING, - cover: bytes | None = MISSING, - image: bytes | None = MISSING, - privacy_level: ScheduledEventPrivacyLevel = ScheduledEventPrivacyLevel.guild_only, ) -> ScheduledEvent | None: """|coro| - Edits the Scheduled Event's data + Edits the Scheduled Event's data. + + All parameters are optional. + + .. note:: + + When changing entity_type to EXTERNAL via entity_metadata, Discord will + automatically set ``channel_id`` to null. + + .. note:: - All parameters are optional unless ``location.type`` is - :attr:`ScheduledEventLocationType.external`, then ``end_time`` - is required. + The Discord API silently discards ``entity_metadata`` for non-EXTERNAL events. Will return a new :class:`.ScheduledEvent` object if applicable. Parameters ---------- name: :class:`str` - The new name of the event. + The new name of the event (1-100 characters). description: :class:`str` - The new description of the event. - location: :class:`.ScheduledEventLocation` - The location of the event. + The new description of the event (1-1000 characters). status: :class:`ScheduledEventStatus` The status of the event. It is recommended, however, to use :meth:`.start`, :meth:`.complete`, and - :meth:`cancel` to edit statuses instead. - start_time: :class:`datetime.datetime` - The new starting time for the event. - end_time: :class:`datetime.datetime` - The new ending time of the event. + :meth:`.cancel` to edit statuses instead. + Valid transitions: SCHEDULED → ACTIVE, ACTIVE → COMPLETED, SCHEDULED → CANCELED. + scheduled_start_time: :class:`datetime.datetime` + The new starting time for the event (ISO8601 format). + scheduled_end_time: :class:`datetime.datetime` + The new ending time of the event (ISO8601 format). privacy_level: :class:`ScheduledEventPrivacyLevel` - The privacy level of the event. Currently, the only possible value - is :attr:`ScheduledEventPrivacyLevel.guild_only`, which is default, - so there is no need to change this parameter. + The privacy level of the event. Currently only GUILD_ONLY is supported. + entity_type: :class:`ScheduledEventEntityType` + The type of scheduled event. When changing to EXTERNAL, you must also provide + ``entity_metadata`` with a location and ``scheduled_end_time``. + entity_metadata: Optional[:class:`ScheduledEventEntityMetadata`] + Additional metadata for the scheduled event. + When set for EXTERNAL events, must contain a location. + Will be silently discarded by Discord for non-EXTERNAL events. reason: Optional[:class:`str`] The reason to show in the audit log. image: Optional[:class:`bytes`] The cover image of the scheduled event. cover: Optional[:class:`bytes`] The cover image of the scheduled event. - .. deprecated:: 2.7 - Use the `image` argument instead. + Use the ``image`` parameter instead. Returns ------- @@ -343,6 +492,8 @@ async def edit( You do not have the Manage Events permission. HTTPException The operation failed. + ValidationError + Invalid parameters for the event type. """ payload: dict[str, Any] = {} @@ -358,49 +509,81 @@ async def edit( if privacy_level is not MISSING: payload["privacy_level"] = int(privacy_level) + if location is not MISSING: + warn_deprecated("location", "entity_metadata", "2.9", "3.0") + if entity_metadata is MISSING: + if not isinstance(location, ScheduledEventLocation): + location = ScheduledEventLocation( + state=self._state, + value=location, + _suppress_deprecation=True, + ) + if entity_type is MISSING: + entity_type = location.type + if location.type == ScheduledEventEntityType.external: + entity_metadata = ScheduledEventEntityMetadata(str(location)) + else: + payload["channel_id"] = location.value.id + payload["entity_metadata"] = None + if cover is not MISSING: - warn_deprecated("cover", "image", "2.7") - if image is not MISSING: - raise InvalidArgument( - "cannot pass both `image` and `cover` to `ScheduledEvent.edit`" - ) - else: + warn_deprecated("cover", "image", "2.7", "3.0") + if image is MISSING: image = cover + if start_time is not MISSING: + warn_deprecated("start_time", "scheduled_start_time", "2.9", "3.0") + if scheduled_start_time is MISSING: + scheduled_start_time = start_time + + if end_time is not MISSING: + warn_deprecated("end_time", "scheduled_end_time", "2.9", "3.0") + if scheduled_end_time is MISSING: + scheduled_end_time = end_time + + if entity_type is not MISSING: + payload["entity_type"] = int(entity_type) + + if entity_metadata is not MISSING: + if entity_metadata is None: + payload["entity_metadata"] = None + else: + payload["entity_metadata"] = entity_metadata.to_payload() + if image is not MISSING: if image is None: payload["image"] = None else: payload["image"] = utils._bytes_to_base64_data(image) - if location is not MISSING: - if not isinstance( - location, (ScheduledEventLocation, utils._MissingSentinel) - ): - location = ScheduledEventLocation(state=self._state, value=location) - - if location.type is ScheduledEventLocationType.external: - payload["channel_id"] = None - payload["entity_metadata"] = {"location": str(location.value)} - else: - payload["channel_id"] = location.value.id - payload["entity_metadata"] = None + if scheduled_start_time is not MISSING: + payload["scheduled_start_time"] = scheduled_start_time.isoformat() - payload["entity_type"] = location.type.value + if scheduled_end_time is not MISSING: + payload["scheduled_end_time"] = scheduled_end_time.isoformat() - location = location if location is not MISSING else self.location - if end_time is MISSING and location.type is ScheduledEventLocationType.external: - end_time = self.end_time - if end_time is None: + if ( + entity_type is not MISSING + and entity_type == ScheduledEventEntityType.external + ): + if entity_metadata is MISSING or entity_metadata is None: + raise ValidationError( + "entity_metadata with a location is required when entity_type is external." + ) + if not entity_metadata.location: raise ValidationError( - "end_time needs to be passed if location type is external." + "entity_metadata.location cannot be empty for external events." ) - if start_time is not MISSING: - payload["scheduled_start_time"] = start_time.isoformat() + has_end_time = ( + scheduled_end_time is not MISSING or self.scheduled_end_time is not None + ) + if not has_end_time: + raise ValidationError( + "scheduled_end_time is required for external events." + ) - if end_time is not MISSING: - payload["scheduled_end_time"] = end_time.isoformat() + payload["channel_id"] = None if payload != {}: data = await self._state.http.edit_scheduled_event( @@ -515,6 +698,7 @@ def subscribers( as_member: bool = False, before: Snowflake | datetime.datetime | None = None, after: Snowflake | datetime.datetime | None = None, + use_cache: bool = False, ) -> ScheduledEventSubscribersIterator: """Returns an :class:`AsyncIterator` representing the users or members subscribed to the event. @@ -542,6 +726,12 @@ def subscribers( Retrieves users after this date or object. If a datetime is provided, it is recommended to use a UTC aware datetime. If the datetime is naive, it is assumed to be local time. + use_cache: Optional[:class:`bool`] + If ``True``, only use cached subscribers and skip API calls. + This is useful when calling from an event handler where the + event may have been deleted. Defaults to ``False``. + Only members currently cached in the guild are yielded, and + ``as_member=False`` is ignored with a warning. Yields ------ @@ -572,7 +762,17 @@ def subscribers( async for member in event.subscribers(limit=100, as_member=True): print(member.display_name) + + Using only cached subscribers (e.g., in a delete event handler): :: + + async for member in event.subscribers(limit=100, as_member=True, use_cache=True): + print(member.display_name) """ return ScheduledEventSubscribersIterator( - event=self, limit=limit, with_member=as_member, before=before, after=after + event=self, + limit=limit, + with_member=as_member, + before=before, + after=after, + use_cache=use_cache, ) diff --git a/discord/state.py b/discord/state.py index 574c973c52..0420c61696 100644 --- a/discord/state.py +++ b/discord/state.py @@ -1678,10 +1678,12 @@ def parse_guild_scheduled_event_update(self, data) -> None: if not data.get("creator", None) else guild.get_member(data.get("creator_id")) ) + old_event = guild.get_scheduled_event(int(data["id"])) scheduled_event = ScheduledEvent( state=self, guild=guild, creator=creator, data=data ) - old_event = guild.get_scheduled_event(int(data["id"])) + if old_event is not None: + scheduled_event._cached_subscribers = old_event._cached_subscribers.copy() guild._add_scheduled_event(scheduled_event) self.dispatch("scheduled_event_update", old_event, scheduled_event) @@ -1702,10 +1704,15 @@ def parse_guild_scheduled_event_delete(self, data) -> None: if not data.get("creator", None) else guild.get_member(data.get("creator_id")) ) + cached_event = guild.get_scheduled_event(int(data["id"])) scheduled_event = ScheduledEvent( state=self, guild=guild, creator=creator, data=data ) scheduled_event.status = ScheduledEventStatus.canceled + if cached_event is not None: + scheduled_event._cached_subscribers = ( + cached_event._cached_subscribers.copy() + ) guild._remove_scheduled_event(scheduled_event) self.dispatch("scheduled_event_delete", scheduled_event) @@ -1725,12 +1732,14 @@ def parse_guild_scheduled_event_user_add(self, data) -> None: payload.guild = guild self.dispatch("raw_scheduled_event_user_add", payload) - member = guild.get_member(data["user_id"]) - if member is not None: - event = guild.get_scheduled_event(data["guild_scheduled_event_id"]) - if event: - event.subscriber_count += 1 - guild._add_scheduled_event(event) + user_id = int(data["user_id"]) + event = guild.get_scheduled_event(int(data["guild_scheduled_event_id"])) + if event: + event._cached_subscribers.add(user_id) + if event.user_count is not None: + event.user_count += 1 + member = guild.get_member(user_id) + if member is not None: self.dispatch("scheduled_event_user_add", event, member) def parse_guild_scheduled_event_user_remove(self, data) -> None: @@ -1749,12 +1758,14 @@ def parse_guild_scheduled_event_user_remove(self, data) -> None: payload.guild = guild self.dispatch("raw_scheduled_event_user_remove", payload) - member = guild.get_member(data["user_id"]) - if member is not None: - event = guild.get_scheduled_event(data["guild_scheduled_event_id"]) - if event: - event.subscriber_count += 1 - guild._add_scheduled_event(event) + user_id = int(data["user_id"]) + event = guild.get_scheduled_event(int(data["guild_scheduled_event_id"])) + if event: + event._cached_subscribers.discard(user_id) + if event.user_count is not None: + event.user_count -= 1 + member = guild.get_member(user_id) + if member is not None: self.dispatch("scheduled_event_user_remove", event, member) def parse_guild_integrations_update(self, data) -> None: diff --git a/discord/types/scheduled_events.py b/discord/types/scheduled_events.py index 9bb4ad0328..130dc99cc6 100644 --- a/discord/types/scheduled_events.py +++ b/discord/types/scheduled_events.py @@ -31,7 +31,7 @@ from .user import User ScheduledEventStatus = Literal[1, 2, 3, 4] -ScheduledEventLocationType = Literal[1, 2, 3] +ScheduledEventEntityType = Literal[1, 2, 3] ScheduledEventPrivacyLevel = Literal[2] @@ -47,7 +47,7 @@ class ScheduledEvent(TypedDict): scheduled_end_time: str | None privacy_level: ScheduledEventPrivacyLevel status: ScheduledEventStatus - entity_type: ScheduledEventLocationType + entity_type: ScheduledEventEntityType entity_id: Snowflake entity_metadata: ScheduledEventEntityMetadata creator: User