From 43227c4086adbd5fd13238eaab04e4edbab12f3d Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Tue, 9 Dec 2025 15:49:29 +0200 Subject: [PATCH 01/13] at first i only wanted to add cached members... --- discord/enums.py | 9 ++ discord/http.py | 1 + discord/iterators.py | 26 +++- discord/scheduled_events.py | 236 +++++++++++++++++++++++++----------- discord/state.py | 26 ++-- 5 files changed, 211 insertions(+), 87 deletions(-) diff --git a/discord/enums.py b/discord/enums.py index 63557c853b..dbecaf73a6 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -61,6 +61,7 @@ "EmbeddedActivity", "ScheduledEventStatus", "ScheduledEventPrivacyLevel", + "ScheduledEventEntityType", "ScheduledEventLocationType", "InputTextStyle", "SlashCommandOptionType", @@ -955,6 +956,14 @@ def __int__(self): return self.value +class ScheduledEventEntityType(Enum): + """Scheduled event entity type""" + + stage_instance = 1 + voice = 2 + external = 3 + + class ScheduledEventLocationType(Enum): """Scheduled event location type""" diff --git a/discord/http.py b/discord/http.py index ae64703ba6..14a4c22562 100644 --- a/discord/http.py +++ b/discord/http.py @@ -2467,6 +2467,7 @@ def edit_scheduled_event( "status", "entity_metadata", "image", + "recurrence_rule", ) payload = {k: v for k, v in payload.items() if k in valid_keys} diff --git a/discord/iterators.py b/discord/iterators.py index b074aefdc4..0f8ac5244b 100644 --- a/discord/iterators.py +++ b/discord/iterators.py @@ -27,6 +27,7 @@ import asyncio import datetime +import itertools from typing import ( TYPE_CHECKING, Any, @@ -898,6 +899,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)) @@ -909,6 +911,7 @@ def __init__( self.with_member = with_member self.before = before self.after = after + self.use_cache = use_cache self.subscribers = asyncio.Queue() self.get_subscribers = self.event._state.http.get_scheduled_event_users @@ -948,12 +951,28 @@ 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.keys()) + + for user_id in itertools.islice(iter(cached_user_ids), self.retrieve): + member = self.event.guild.get_member(user_id) + if member: + await self.subscribers.put(member) + + 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, @@ -966,9 +985,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): @@ -1277,7 +1295,7 @@ async def retrieve_inner(self) -> list[Message]: def __await__(self) -> Generator[Any, Any, MessagePin]: warn_deprecated( - f"Messageable.pins() returning a list of Message", + "Messageable.pins() returning a list of Message", since="2.7", removed="3.0", reference="The documentation of pins()", diff --git a/discord/scheduled_events.py b/discord/scheduled_events.py index 7e339dcee7..5fab94d673 100644 --- a/discord/scheduled_events.py +++ b/discord/scheduled_events.py @@ -30,12 +30,13 @@ from . import utils from .asset import Asset from .enums import ( + ScheduledEventEntityType, ScheduledEventLocationType, 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,12 +45,12 @@ __all__ = ( "ScheduledEvent", "ScheduledEventLocation", + "ScheduledEventEntityMetadata", ) if TYPE_CHECKING: from .abc import Snowflake from .guild import Guild - from .iterators import AsyncIterator from .member import Member from .state import ConnectionState from .types.channel import StageChannel, VoiceChannel @@ -115,6 +116,42 @@ def type(self) -> ScheduledEventLocationType: return ScheduledEventLocationType.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.7 + + Attributes + ---------- + location: Optional[:class:`str`] + The location of the event (1-100 characters). Only present for EXTERNAL events. + """ + + __slots__ = ("location",) + + def __init__(self, *, data: dict[str, str]) -> str | None: + self.location: str | None = data.get("location") + + def __repr__(self) -> str: + return f"" + + def __str__(self) -> str: + return self.location or "" + + def to_payload(self) -> dict[str, str]: + """Converts the entity metadata to a Discord API payload. + + Returns + ------- + dict[str, str] + A dictionary with the entity metadata fields for the API. + """ + return {"location": self.location} + + class ScheduledEvent(Hashable): """Represents a Discord Guild Scheduled Event. @@ -155,7 +192,7 @@ class ScheduledEvent(Hashable): 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,6 +204,14 @@ 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). + recurrence_rule: Optional[:class:`dict`] + The definition for how often this event should recur. """ __slots__ = ( @@ -182,7 +227,14 @@ class ScheduledEvent(Hashable): "guild", "_state", "_image", - "subscriber_count", + "user_count", + "_cached_subscribers", + "entity_type", + "privacy_level", + "recurrence_rule", + "channel_id", + "entity_id", + "entity_metadata", ) def __init__( @@ -209,15 +261,32 @@ def __init__( 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.recurrence_rule: dict | None = data.get("recurrence_rule", None) + 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(data=entity_metadata_data) + if entity_metadata_data + else None + ) + + self._cached_subscribers: dict[int, int] = {} + 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: + if channel_id is None and entity_metadata_data: self.location = ScheduledEventLocation( - state=state, value=entity_metadata["location"] + state=state, value=entity_metadata_data["location"] ) else: self.location = ScheduledEventLocation(state=state, value=int(channel_id)) @@ -234,7 +303,7 @@ def __repr__(self) -> str: f"end_time={self.end_time} " f"location={self.location!r} " f"status={self.status.name} " - f"subscriber_count={self.subscriber_count} " + f"user_count={self.user_count} " f"creator_id={self.creator_id}>" ) @@ -245,8 +314,8 @@ def created_at(self) -> datetime.datetime: @property 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: @@ -282,54 +351,62 @@ async def edit( name: str = MISSING, description: str = MISSING, status: int | ScheduledEventStatus = MISSING, - location: ( - str | int | VoiceChannel | StageChannel | ScheduledEventLocation - ) = MISSING, + entity_type: ScheduledEventEntityType = 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, + privacy_level: ScheduledEventPrivacyLevel = MISSING, + entity_metadata: ScheduledEventEntityMetadata | None = MISSING, + recurrence_rule: dict = MISSING, ) -> 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. + :meth:`.cancel` to edit statuses instead. + Valid transitions: SCHEDULED → ACTIVE, ACTIVE → COMPLETED, SCHEDULED → CANCELED. + 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``. start_time: :class:`datetime.datetime` - The new starting time for the event. + The new starting time for the event (ISO8601 format). end_time: :class:`datetime.datetime` - The new ending time of the event. + 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_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. + recurrence_rule: :class:`dict` + The definition for how often this event should recur. 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. Returns ------- @@ -343,6 +420,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] = {} @@ -355,17 +434,20 @@ async def edit( if status is not MISSING: payload["status"] = int(status) + if entity_type is not MISSING: + payload["entity_type"] = int(entity_type) + if privacy_level is not MISSING: payload["privacy_level"] = int(privacy_level) - 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`" - ) + if entity_metadata is not MISSING: + if entity_metadata is None: + payload["entity_metadata"] = None else: - image = cover + payload["entity_metadata"] = entity_metadata.to_payload() + + if recurrence_rule is not MISSING: + payload["recurrence_rule"] = recurrence_rule if image is not MISSING: if image is None: @@ -373,42 +455,39 @@ async def edit( 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 start_time is not MISSING: + payload["scheduled_start_time"] = start_time.isoformat() - payload["entity_type"] = location.type.value + if end_time is not MISSING: + payload["scheduled_end_time"] = 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 = end_time is not MISSING or self.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( - self.guild.id, self.id, **payload, reason=reason - ) - return ScheduledEvent( - data=data, guild=self.guild, creator=self.creator, state=self._state - ) + data = await self._state.http.edit_scheduled_event( + self.guild.id, self.id, **payload, reason=reason + ) + return ScheduledEvent( + data=data, guild=self.guild, creator=self.creator, state=self._state + ) async def delete(self) -> None: """|coro| @@ -515,6 +594,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 +622,10 @@ 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``. Yields ------ @@ -572,7 +656,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 8222f5fbe5..665c5cbd31 100644 --- a/discord/state.py +++ b/discord/state.py @@ -1703,12 +1703,13 @@ 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[user_id] = user_id + guild._add_scheduled_event(event) + 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: @@ -1727,12 +1728,13 @@ 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.pop(user_id, None) + guild._add_scheduled_event(event) + 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: From fe47678e5ac559d1eb6cdf93e74f972f8707bb9f Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Tue, 9 Dec 2025 16:07:30 +0200 Subject: [PATCH 02/13] correcting some bs, like some unknow type added for i dont know what reason --- discord/audit_logs.py | 34 ++++++------- discord/enums.py | 8 --- discord/guild.py | 84 +++++++++++++++++++++++-------- discord/http.py | 1 + discord/scheduled_events.py | 9 ++-- discord/types/scheduled_events.py | 4 +- 6 files changed, 85 insertions(+), 55 deletions(-) diff --git a/discord/audit_logs.py b/discord/audit_logs.py index b2f6a72393..d9a1a8920c 100644 --- a/discord/audit_logs.py +++ b/discord/audit_logs.py @@ -318,7 +318,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 +331,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 +357,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: @@ -691,7 +684,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 dbecaf73a6..a5ee990f14 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -964,14 +964,6 @@ class ScheduledEventEntityType(Enum): external = 3 -class ScheduledEventLocationType(Enum): - """Scheduled event location type""" - - stage_instance = 1 - voice = 2 - external = 3 - - class AutoModTriggerType(Enum): """Automod trigger type""" diff --git a/discord/guild.py b/discord/guild.py index f910affe5c..423c7fc43b 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -58,7 +58,7 @@ NotificationLevel, NSFWLevel, OnboardingMode, - ScheduledEventLocationType, + ScheduledEventEntityType, ScheduledEventPrivacyLevel, SortOrder, VerificationLevel, @@ -66,7 +66,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 @@ -84,7 +90,10 @@ 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, +) from .soundboard import SoundboardSound from .stage_instance import StageInstance from .sticker import GuildSticker @@ -4215,14 +4224,20 @@ async def create_scheduled_event( description: str = MISSING, start_time: datetime.datetime, end_time: datetime.datetime = MISSING, - location: str | int | VoiceChannel | StageChannel | ScheduledEventLocation, + entity_type: ScheduledEventEntityType, + entity_metadata: ScheduledEventEntityMetadata | None = MISSING, + channel_id: int = MISSING, privacy_level: ScheduledEventPrivacyLevel = ScheduledEventPrivacyLevel.guild_only, reason: str | None = None, image: bytes = MISSING, + recurrence_rule: dict = 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` @@ -4233,16 +4248,23 @@ async def create_scheduled_event( A datetime object of when the scheduled event is supposed to start. 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`] The cover image of the scheduled event + recurrence_rule: Optional[:class:`dict`] + The definition for how often this event should recur. Returns ------- @@ -4255,34 +4277,52 @@ 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), + "entity_type": int(entity_type.value), } - if not isinstance(location, ScheduledEventLocation): - location = ScheduledEventLocation(state=self._state, value=location) - - payload["entity_type"] = location.type.value - - if location.type == ScheduledEventLocationType.external: - payload["channel_id"] = None - payload["entity_metadata"] = {"location": location.value} - else: - payload["channel_id"] = location.value.id - payload["entity_metadata"] = None + if end_time is not MISSING: + payload["scheduled_end_time"] = 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 recurrence_rule is not MISSING: + payload["recurrence_rule"] = recurrence_rule + + 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 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/http.py b/discord/http.py index 14a4c22562..d1c831e9a4 100644 --- a/discord/http.py +++ b/discord/http.py @@ -2428,6 +2428,7 @@ def create_scheduled_event( "entity_type", "entity_metadata", "image", + "recurrence_rule", ) payload = {k: v for k, v in payload.items() if k in valid_keys} diff --git a/discord/scheduled_events.py b/discord/scheduled_events.py index 5fab94d673..fadb0d68ea 100644 --- a/discord/scheduled_events.py +++ b/discord/scheduled_events.py @@ -31,7 +31,6 @@ from .asset import Asset from .enums import ( ScheduledEventEntityType, - ScheduledEventLocationType, ScheduledEventPrivacyLevel, ScheduledEventStatus, try_enum, @@ -107,13 +106,13 @@ def __str__(self) -> str: return str(self.value) @property - def type(self) -> ScheduledEventLocationType: + def type(self) -> ScheduledEventEntityType: 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 class ScheduledEventEntityMetadata: 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 From fff9b3bd9d0b55d54fc0bf3ef2368f35935074bc Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Tue, 9 Dec 2025 16:40:44 +0200 Subject: [PATCH 03/13] use discord variable name, try an implementation for audit logs --- discord/audit_logs.py | 24 ++++++++++++------ discord/enums.py | 2 +- discord/guild.py | 16 ++++++------ discord/scheduled_events.py | 50 ++++++++++++++++++++----------------- 4 files changed, 53 insertions(+), 39 deletions(-) diff --git a/discord/audit_logs.py b/discord/audit_logs.py index d9a1a8920c..573427fb80 100644 --- a/discord/audit_logs.py +++ b/discord/audit_logs.py @@ -278,8 +278,8 @@ class AuditLogChanges: "type": (None, _transform_type), "status": (None, _enum_transformer(enums.ScheduledEventStatus)), "entity_type": ( - "location_type", - _enum_transformer(enums.ScheduledEventLocationType), + "entity_type", + _enum_transformer(enums.ScheduledEventEntityType), ), "command_id": ("command_id", _transform_snowflake), "image_hash": ("image", _transform_scheduled_event_image), @@ -357,6 +357,19 @@ def __init__( if transformer: before = transformer(entry, before) + if attr == "location" and hasattr(self.before, "entity_type"): + from .scheduled_events import ScheduledEventLocation + + if ( + self.before.entity_type + is enums.ScheduledEventEntityType.external + ): + before = ScheduledEventLocation(state=state, value=before) + elif hasattr(self.before, "channel"): + before = ScheduledEventLocation( + state=state, value=self.before.channel + ) + try: after = elem["new_value"] except KeyError: @@ -365,13 +378,10 @@ def __init__( if transformer: after = transformer(entry, after) - if attr == "location" and hasattr(self.after, "location_type"): + if attr == "location" and hasattr(self.after, "entity_type"): from .scheduled_events import ScheduledEventLocation - if ( - self.after.location_type - is enums.ScheduledEventLocationType.external - ): + if self.after.entity_type is enums.ScheduledEventEntityType.external: after = ScheduledEventLocation(state=state, value=after) elif hasattr(self.after, "channel"): after = ScheduledEventLocation( diff --git a/discord/enums.py b/discord/enums.py index a5ee990f14..e4ae91737f 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -62,7 +62,7 @@ "ScheduledEventStatus", "ScheduledEventPrivacyLevel", "ScheduledEventEntityType", - "ScheduledEventLocationType", + "ScheduledEventEntityType", "InputTextStyle", "SlashCommandOptionType", "AutoModTriggerType", diff --git a/discord/guild.py b/discord/guild.py index 423c7fc43b..c4a861e86c 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -4222,8 +4222,8 @@ async def create_scheduled_event( *, name: str, description: str = MISSING, - start_time: datetime.datetime, - end_time: datetime.datetime = MISSING, + scheduled_start_time: datetime.datetime, + scheduled_end_time: datetime.datetime = MISSING, entity_type: ScheduledEventEntityType, entity_metadata: ScheduledEventEntityMetadata | None = MISSING, channel_id: int = MISSING, @@ -4244,9 +4244,9 @@ async def create_scheduled_event( 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. Required for EXTERNAL events. entity_type: :class:`ScheduledEventEntityType` @@ -4282,13 +4282,13 @@ async def create_scheduled_event( """ payload: dict[str, str | int] = { "name": name, - "scheduled_start_time": start_time.isoformat(), + "scheduled_start_time": scheduled_start_time.isoformat(), "privacy_level": int(privacy_level), "entity_type": int(entity_type.value), } - if end_time is not MISSING: - payload["scheduled_end_time"] = end_time.isoformat() + if scheduled_end_time is not MISSING: + payload["scheduled_end_time"] = scheduled_end_time.isoformat() if description is not MISSING: payload["description"] = description @@ -4308,7 +4308,7 @@ async def create_scheduled_event( raise ValidationError( "entity_metadata.location cannot be empty for EXTERNAL events." ) - if end_time is MISSING: + if scheduled_end_time is MISSING: raise ValidationError( "scheduled_end_time is required for EXTERNAL events." ) diff --git a/discord/scheduled_events.py b/discord/scheduled_events.py index fadb0d68ea..60f49cbae8 100644 --- a/discord/scheduled_events.py +++ b/discord/scheduled_events.py @@ -131,8 +131,11 @@ class ScheduledEventEntityMetadata: __slots__ = ("location",) - def __init__(self, *, data: dict[str, str]) -> str | None: - self.location: str | None = data.get("location") + def __init__( + self, + location: str | None = None, + ) -> None: + self.location: str | None = location def __repr__(self) -> str: return f"" @@ -182,9 +185,9 @@ 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. @@ -217,8 +220,8 @@ class ScheduledEvent(Hashable): "id", "name", "description", - "start_time", - "end_time", + "scheduled_start_time", + "scheduled_end_time", "status", "creator_id", "creator", @@ -251,12 +254,12 @@ 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") ) @@ -272,7 +275,7 @@ def __init__( entity_metadata_data = data.get("entity_metadata") self.entity_metadata: ScheduledEventEntityMetadata | None = ( - ScheduledEventEntityMetadata(data=entity_metadata_data) + ScheduledEventEntityMetadata(location=entity_metadata_data.get("location")) if entity_metadata_data else None ) @@ -299,7 +302,7 @@ def __repr__(self) -> str: f"name={self.name} " f"description={self.description} " f"start_time={self.start_time} " - f"end_time={self.end_time} " + f"end_time={self.scheduled_end_time} " f"location={self.location!r} " f"status={self.status.name} " f"user_count={self.user_count} " @@ -351,9 +354,8 @@ async def edit( description: str = MISSING, status: int | ScheduledEventStatus = MISSING, entity_type: ScheduledEventEntityType = MISSING, - start_time: datetime.datetime = MISSING, - end_time: datetime.datetime = MISSING, - cover: bytes | None = MISSING, + scheduled_start_time: datetime.datetime = MISSING, + scheduled_end_time: datetime.datetime = MISSING, image: bytes | None = MISSING, privacy_level: ScheduledEventPrivacyLevel = MISSING, entity_metadata: ScheduledEventEntityMetadata | None = MISSING, @@ -390,9 +392,9 @@ async def edit( 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``. - start_time: :class:`datetime.datetime` + scheduled_start_time: :class:`datetime.datetime` The new starting time for the event (ISO8601 format). - end_time: :class:`datetime.datetime` + 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 only GUILD_ONLY is supported. @@ -434,7 +436,7 @@ async def edit( payload["status"] = int(status) if entity_type is not MISSING: - payload["entity_type"] = int(entity_type) + payload["entity_type"] = int(entity_type.value) if privacy_level is not MISSING: payload["privacy_level"] = int(privacy_level) @@ -454,11 +456,11 @@ async def edit( else: payload["image"] = utils._bytes_to_base64_data(image) - if start_time is not MISSING: - payload["scheduled_start_time"] = start_time.isoformat() + if scheduled_start_time is not MISSING: + payload["scheduled_start_time"] = scheduled_start_time.isoformat() - if end_time is not MISSING: - payload["scheduled_end_time"] = end_time.isoformat() + if scheduled_end_time is not MISSING: + payload["scheduled_end_time"] = scheduled_end_time.isoformat() if ( entity_type is not MISSING @@ -473,7 +475,9 @@ async def edit( "entity_metadata.location cannot be empty for EXTERNAL events." ) - has_end_time = end_time is not MISSING or self.end_time is not None + 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." From 9e6a986a89e9070f8a24a5d2103595472cb5b1c7 Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Tue, 9 Dec 2025 19:09:12 +0200 Subject: [PATCH 04/13] feat: add recurrence support for scheduled events with new enums and types --- discord/enums.py | 41 ++++++ discord/guild.py | 10 +- discord/scheduled_events.py | 209 +++++++++++++++++++++++++++++- discord/types/scheduled_events.py | 35 +++++ 4 files changed, 285 insertions(+), 10 deletions(-) diff --git a/discord/enums.py b/discord/enums.py index e4ae91737f..b9fe919ede 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -63,6 +63,9 @@ "ScheduledEventPrivacyLevel", "ScheduledEventEntityType", "ScheduledEventEntityType", + "ScheduledEventRecurrenceFrequency", + "ScheduledEventRecurrenceWeekday", + "ScheduledEventRecurrenceMonth", "InputTextStyle", "SlashCommandOptionType", "AutoModTriggerType", @@ -964,6 +967,44 @@ class ScheduledEventEntityType(Enum): external = 3 +class ScheduledEventRecurrenceFrequency(Enum): + """Scheduled event recurrence frequency""" + + yearly = 0 + monthly = 1 + weekly = 2 + daily = 3 + + +class ScheduledEventRecurrenceWeekday(Enum): + """Scheduled event recurrence weekday""" + + monday = 0 + tuesday = 1 + wednesday = 2 + thursday = 3 + friday = 4 + saturday = 5 + sunday = 6 + + +class ScheduledEventRecurrenceMonth(Enum): + """Scheduled event recurrence month""" + + january = 1 + february = 2 + march = 3 + april = 4 + may = 5 + june = 6 + july = 7 + august = 8 + september = 9 + october = 10 + november = 11 + december = 12 + + class AutoModTriggerType(Enum): """Automod trigger type""" diff --git a/discord/guild.py b/discord/guild.py index c4a861e86c..20a96c059a 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -93,6 +93,7 @@ from .scheduled_events import ( ScheduledEvent, ScheduledEventEntityMetadata, + ScheduledEventRecurrenceRule, ) from .soundboard import SoundboardSound from .stage_instance import StageInstance @@ -4230,7 +4231,7 @@ async def create_scheduled_event( privacy_level: ScheduledEventPrivacyLevel = ScheduledEventPrivacyLevel.guild_only, reason: str | None = None, image: bytes = MISSING, - recurrence_rule: dict = MISSING, + recurrence_rule: ScheduledEventRecurrenceRule | None = MISSING, ) -> ScheduledEvent | None: """|coro| Creates a scheduled event. @@ -4263,7 +4264,7 @@ async def create_scheduled_event( The reason to show in the audit log. image: Optional[:class:`bytes`] The cover image of the scheduled event - recurrence_rule: Optional[:class:`dict`] + recurrence_rule: Optional[Union[:class:`ScheduledEventRecurrenceRule`, :class:`dict`]] The definition for how often this event should recur. Returns @@ -4297,7 +4298,10 @@ async def create_scheduled_event( payload["image"] = utils._bytes_to_base64_data(image) if recurrence_rule is not MISSING: - payload["recurrence_rule"] = recurrence_rule + if recurrence_rule is None: + payload["recurrence_rule"] = None + else: + payload["recurrence_rule"] = recurrence_rule.to_payload() if entity_type == ScheduledEventEntityType.external: if entity_metadata is MISSING or entity_metadata is None: diff --git a/discord/scheduled_events.py b/discord/scheduled_events.py index 60f49cbae8..76679fbcb7 100644 --- a/discord/scheduled_events.py +++ b/discord/scheduled_events.py @@ -33,6 +33,9 @@ ScheduledEventEntityType, ScheduledEventPrivacyLevel, ScheduledEventStatus, + ScheduledEventRecurrenceFrequency, + ScheduledEventRecurrenceMonth, + ScheduledEventRecurrenceWeekday, try_enum, ) from .errors import ValidationError @@ -45,6 +48,8 @@ "ScheduledEvent", "ScheduledEventLocation", "ScheduledEventEntityMetadata", + "ScheduledEventRecurrenceRule", + "ScheduledEventRecurrenceNWeekday", ) if TYPE_CHECKING: @@ -53,7 +58,10 @@ from .member import Member from .state import ConnectionState from .types.channel import StageChannel, VoiceChannel - from .types.scheduled_events import ScheduledEvent as ScheduledEventPayload + from .types.scheduled_events import ( + ScheduledEvent as ScheduledEventPayload, + ScheduledEventRecurrenceRule as ScheduledEventRecurrenceRulePayload, + ) MISSING = utils.MISSING @@ -154,6 +162,185 @@ def to_payload(self) -> dict[str, str]: return {"location": self.location} +class ScheduledEventRecurrenceNWeekday: + """Represents a recurrence rule n-weekday entry. + + Attributes + ---------- + n: :class:`int` + The week to reoccur on. 1 - 5. + day: :class:`ScheduledEventRecurrenceWeekday` + The day within the week to reoccur on. + """ + + __slots__ = ("n", "day") + + def __init__(self, *, n: int, day: ScheduledEventRecurrenceWeekday | int) -> None: + self.n: int = n + self.day: ScheduledEventRecurrenceWeekday = try_enum( + ScheduledEventRecurrenceWeekday, day + ) + + def __repr__(self) -> str: + return f"" + + def to_payload(self) -> dict[str, int]: + return {"n": int(self.n), "day": int(self.day)} + + +class ScheduledEventRecurrenceRule: + """Represents a recurrence rule for a scheduled event. + + Discord's recurrence rule is a subset of :mod:`dateutil.rrule` / iCalendar. + + Attributes + ---------- + start: :class:`datetime.datetime` + Starting time of the recurrence interval. + end: Optional[:class:`datetime.datetime`] + Ending time of the recurrence interval. + frequency: :class:`ScheduledEventRecurrenceFrequency` + How often the event occurs. + interval: :class:`int` + The spacing between events for the given frequency. + by_weekday: Optional[list[:class:`ScheduledEventRecurrenceWeekday`]] + Specific days within a week for the event to recur on. + by_n_weekday: Optional[list[:class:`ScheduledEventRecurrenceNWeekday`]] + Specific days within a specific week to recur on. + by_month: Optional[list[:class:`ScheduledEventRecurrenceMonth`]] + Specific months for the event to recur on. + by_month_day: Optional[list[:class:`int`]] + Specific dates within a month for the event to recur on. + by_year_day: Optional[list[:class:`int`]] + Specific day numbers within a year for the event to recur on (1-364). + count: Optional[:class:`int`] + Number of times the event can recur before stopping. + """ + + __slots__ = ( + "start", + "end", + "frequency", + "interval", + "by_weekday", + "by_n_weekday", + "by_month", + "by_month_day", + "by_year_day", + "count", + ) + + def __init__( + self, + *, + start: datetime.datetime, + frequency: ScheduledEventRecurrenceFrequency | int, + interval: int, + end: datetime.datetime | None = None, + by_weekday: list[ScheduledEventRecurrenceWeekday | int] | None = None, + by_n_weekday: list[ScheduledEventRecurrenceNWeekday | dict[str, int]] + | None = None, + by_month: list[ScheduledEventRecurrenceMonth | int] | None = None, + by_month_day: list[int] | None = None, + by_year_day: list[int] | None = None, + count: int | None = None, + ) -> None: + self.start: datetime.datetime = start + self.end: datetime.datetime | None = end + self.frequency: ScheduledEventRecurrenceFrequency = try_enum( + ScheduledEventRecurrenceFrequency, frequency + ) + self.interval: int = interval + self.by_weekday: list[ScheduledEventRecurrenceWeekday] | None = ( + [try_enum(ScheduledEventRecurrenceWeekday, day) for day in by_weekday] + if by_weekday is not None + else None + ) + if by_n_weekday is not None: + self.by_n_weekday = [ + entry + if isinstance(entry, ScheduledEventRecurrenceNWeekday) + else ScheduledEventRecurrenceNWeekday(**entry) + for entry in by_n_weekday + ] + else: + self.by_n_weekday = None + self.by_month: list[ScheduledEventRecurrenceMonth] | None = ( + [try_enum(ScheduledEventRecurrenceMonth, month) for month in by_month] + if by_month is not None + else None + ) + self.by_month_day: list[int] | None = by_month_day + self.by_year_day: list[int] | None = by_year_day + self.count: int | None = count + + def __repr__(self) -> str: + return ( + f"" + ) + + @classmethod + def from_data( + cls, data: ScheduledEventRecurrenceRulePayload + ) -> ScheduledEventRecurrenceRule: + start = utils.parse_time(data["start"]) + end = utils.parse_time(data.get("end")) + by_weekday = data.get("by_weekday") + + raw_by_n_weekday = data.get("by_n_weekday") + by_n_weekday = ( + [ScheduledEventRecurrenceNWeekday(**entry) for entry in raw_by_n_weekday] + if raw_by_n_weekday + else None + ) + + return cls( + start=start, + end=end, + frequency=data["frequency"], + interval=data["interval"], + by_weekday=by_weekday, + by_n_weekday=by_n_weekday, + by_month=data.get("by_month"), + by_month_day=data.get("by_month_day"), + by_year_day=data.get("by_year_day"), + count=data.get("count"), + ) + + def to_payload(self) -> dict[str, Any]: + payload: dict[str, Any] = { + "start": self.start.isoformat(), + "frequency": int(self.frequency), + "interval": int(self.interval), + } + + if self.end is not None: + payload["end"] = self.end.isoformat() + + if self.by_weekday is not None: + payload["by_weekday"] = [int(day) for day in self.by_weekday] + + if self.by_n_weekday is not None: + payload["by_n_weekday"] = [ + entry.to_payload() for entry in self.by_n_weekday + ] + + if self.by_month is not None: + payload["by_month"] = [int(month) for month in self.by_month] + + if self.by_month_day is not None: + payload["by_month_day"] = self.by_month_day + + if self.by_year_day is not None: + payload["by_year_day"] = self.by_year_day + + if self.count is not None: + payload["count"] = self.count + + return payload + + class ScheduledEvent(Hashable): """Represents a Discord Guild Scheduled Event. @@ -212,7 +399,7 @@ class ScheduledEvent(Hashable): 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). - recurrence_rule: Optional[:class:`dict`] + recurrence_rule: Optional[:class:`ScheduledEventRecurrenceRule`] The definition for how often this event should recur. """ @@ -269,7 +456,12 @@ def __init__( self.privacy_level: ScheduledEventPrivacyLevel = try_enum( ScheduledEventPrivacyLevel, data.get("privacy_level") ) - self.recurrence_rule: dict | None = data.get("recurrence_rule", None) + recurrence_rule_data = data.get("recurrence_rule") + self.recurrence_rule: ScheduledEventRecurrenceRule | None = ( + ScheduledEventRecurrenceRule.from_data(recurrence_rule_data) + if recurrence_rule_data + else None + ) self.channel_id: int | None = utils._get_as_snowflake(data, "channel_id") self.entity_id: int | None = utils._get_as_snowflake(data, "entity_id") @@ -301,7 +493,7 @@ def __repr__(self) -> str: f" ScheduledEvent | None: """|coro| @@ -402,7 +594,7 @@ async def edit( 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. - recurrence_rule: :class:`dict` + recurrence_rule: Union[:class:`ScheduledEventRecurrenceRule`, :class:`dict`] The definition for how often this event should recur. reason: Optional[:class:`str`] The reason to show in the audit log. @@ -448,7 +640,10 @@ async def edit( payload["entity_metadata"] = entity_metadata.to_payload() if recurrence_rule is not MISSING: - payload["recurrence_rule"] = recurrence_rule + if isinstance(recurrence_rule, ScheduledEventRecurrenceRule): + payload["recurrence_rule"] = recurrence_rule.to_payload() + else: + payload["recurrence_rule"] = recurrence_rule if image is not MISSING: if image is None: diff --git a/discord/types/scheduled_events.py b/discord/types/scheduled_events.py index 130dc99cc6..c86b6b02f5 100644 --- a/discord/types/scheduled_events.py +++ b/discord/types/scheduled_events.py @@ -33,6 +33,40 @@ ScheduledEventStatus = Literal[1, 2, 3, 4] ScheduledEventEntityType = Literal[1, 2, 3] ScheduledEventPrivacyLevel = Literal[2] +ScheduledEventRecurrenceFrequency = Literal[0, 1, 2, 3] +ScheduledEventRecurrenceWeekday = Literal[0, 1, 2, 3, 4, 5, 6] +ScheduledEventRecurrenceMonth = Literal[ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, +] + + +class ScheduledEventRecurrenceNWeekday(TypedDict): + n: int + day: ScheduledEventRecurrenceWeekday + + +class ScheduledEventRecurrenceRule(TypedDict, total=False): + start: str + end: str | None + frequency: ScheduledEventRecurrenceFrequency + interval: int + by_weekday: list[ScheduledEventRecurrenceWeekday] + by_n_weekday: list[ScheduledEventRecurrenceNWeekday] + by_month: list[ScheduledEventRecurrenceMonth] + by_month_day: list[int] + by_year_day: list[int] + count: int class ScheduledEvent(TypedDict): @@ -52,6 +86,7 @@ class ScheduledEvent(TypedDict): entity_metadata: ScheduledEventEntityMetadata creator: User user_count: int | None + recurrence_rule: ScheduledEventRecurrenceRule | None class ScheduledEventEntityMetadata(TypedDict): From 7b2c31f733129567d14f156ac72b7336348895a8 Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Tue, 9 Dec 2025 20:08:17 +0200 Subject: [PATCH 05/13] refactor: remove ScheduledEventLocation class and related attributes from audit logs --- discord/audit_logs.py | 25 ---------- discord/scheduled_events.py | 95 +++++-------------------------------- 2 files changed, 12 insertions(+), 108 deletions(-) diff --git a/discord/audit_logs.py b/discord/audit_logs.py index 573427fb80..9a00f15715 100644 --- a/discord/audit_logs.py +++ b/discord/audit_logs.py @@ -357,19 +357,6 @@ def __init__( if transformer: before = transformer(entry, before) - if attr == "location" and hasattr(self.before, "entity_type"): - from .scheduled_events import ScheduledEventLocation - - if ( - self.before.entity_type - is enums.ScheduledEventEntityType.external - ): - before = ScheduledEventLocation(state=state, value=before) - elif hasattr(self.before, "channel"): - before = ScheduledEventLocation( - state=state, value=self.before.channel - ) - try: after = elem["new_value"] except KeyError: @@ -378,18 +365,6 @@ def __init__( if transformer: after = transformer(entry, after) - if attr == "location" and hasattr(self.after, "entity_type"): - from .scheduled_events import ScheduledEventLocation - - if self.after.entity_type is enums.ScheduledEventEntityType.external: - after = ScheduledEventLocation(state=state, value=after) - elif hasattr(self.after, "channel"): - after = ScheduledEventLocation( - state=state, value=self.after.channel - ) - - setattr(self.after, attr, after) - # add an alias if hasattr(self.after, "colour"): self.after.color = self.after.colour diff --git a/discord/scheduled_events.py b/discord/scheduled_events.py index 76679fbcb7..83918341c1 100644 --- a/discord/scheduled_events.py +++ b/discord/scheduled_events.py @@ -41,12 +41,10 @@ from .errors import ValidationError from .iterators import ScheduledEventSubscribersIterator from .mixins import Hashable -from .object import Object from .utils import warn_deprecated __all__ = ( "ScheduledEvent", - "ScheduledEventLocation", "ScheduledEventEntityMetadata", "ScheduledEventRecurrenceRule", "ScheduledEventRecurrenceNWeekday", @@ -57,7 +55,6 @@ from .guild import Guild from .member import Member from .state import ConnectionState - from .types.channel import StageChannel, VoiceChannel from .types.scheduled_events import ( ScheduledEvent as ScheduledEventPayload, ScheduledEventRecurrenceRule as ScheduledEventRecurrenceRulePayload, @@ -66,63 +63,6 @@ MISSING = utils.MISSING -class ScheduledEventLocation: - """Represents a scheduled event's location. - - Setting the ``value`` to its corresponding type will set the location type automatically: - - +------------------------+---------------------------------------------------+ - | Type of Input | Location Type | - +========================+===================================================+ - | :class:`StageChannel` | :attr:`ScheduledEventLocationType.stage_instance` | - | :class:`VoiceChannel` | :attr:`ScheduledEventLocationType.voice` | - | :class:`str` | :attr:`ScheduledEventLocationType.external` | - +------------------------+---------------------------------------------------+ - - .. versionadded:: 2.0 - - Attributes - ---------- - value: Union[:class:`str`, :class:`StageChannel`, :class:`VoiceChannel`, :class:`Object`] - The actual location of the scheduled event. - type: :class:`ScheduledEventLocationType` - The type of location. - """ - - __slots__ = ( - "_state", - "value", - ) - - 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)) - else: - self.value = value - - def __repr__(self) -> str: - return f"" - - def __str__(self) -> str: - return str(self.value) - - @property - def type(self) -> ScheduledEventEntityType: - if isinstance(self.value, str): - return ScheduledEventEntityType.external - elif self.value.__class__.__name__ == "StageChannel": - return ScheduledEventEntityType.stage_instance - elif self.value.__class__.__name__ == "VoiceChannel": - return ScheduledEventEntityType.voice - - class ScheduledEventEntityMetadata: """Represents a scheduled event's entity metadata. @@ -185,7 +125,7 @@ def __repr__(self) -> str: return f"" def to_payload(self) -> dict[str, int]: - return {"n": int(self.n), "day": int(self.day)} + return {"n": int(self.n), "day": int(self.day.value)} class ScheduledEventRecurrenceRule: @@ -234,7 +174,7 @@ def __init__( self, *, start: datetime.datetime, - frequency: ScheduledEventRecurrenceFrequency | int, + frequency: ScheduledEventRecurrenceFrequency, interval: int, end: datetime.datetime | None = None, by_weekday: list[ScheduledEventRecurrenceWeekday | int] | None = None, @@ -247,9 +187,7 @@ def __init__( ) -> None: self.start: datetime.datetime = start self.end: datetime.datetime | None = end - self.frequency: ScheduledEventRecurrenceFrequency = try_enum( - ScheduledEventRecurrenceFrequency, frequency - ) + self.frequency: ScheduledEventRecurrenceFrequency = frequency self.interval: int = interval self.by_weekday: list[ScheduledEventRecurrenceWeekday] | None = ( [try_enum(ScheduledEventRecurrenceWeekday, day) for day in by_weekday] @@ -311,7 +249,7 @@ def from_data( def to_payload(self) -> dict[str, Any]: payload: dict[str, Any] = { "start": self.start.isoformat(), - "frequency": int(self.frequency), + "frequency": int(self.frequency.value), "interval": int(self.interval), } @@ -319,7 +257,7 @@ def to_payload(self) -> dict[str, Any]: payload["end"] = self.end.isoformat() if self.by_weekday is not None: - payload["by_weekday"] = [int(day) for day in self.by_weekday] + payload["by_weekday"] = [int(day.value) for day in self.by_weekday] if self.by_n_weekday is not None: payload["by_n_weekday"] = [ @@ -327,7 +265,7 @@ def to_payload(self) -> dict[str, Any]: ] if self.by_month is not None: - payload["by_month"] = [int(month) for month in self.by_month] + payload["by_month"] = [int(month.value) for month in self.by_month] if self.by_month_day is not None: payload["by_month_day"] = self.by_month_day @@ -378,9 +316,6 @@ class ScheduledEvent(Hashable): 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. user_count: :class:`int` The number of users that have marked themselves as interested in the event. creator_id: Optional[:class:`int`] @@ -476,14 +411,7 @@ def __init__( 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 - - channel_id = data.get("channel_id", None) - if channel_id is None and entity_metadata_data: - self.location = ScheduledEventLocation( - state=state, value=entity_metadata_data["location"] - ) - else: - self.location = ScheduledEventLocation(state=state, value=int(channel_id)) + self.channel_id = data.get("channel_id", None) def __str__(self) -> str: return self.name @@ -499,6 +427,7 @@ def __repr__(self) -> str: f"status={self.status.name} " f"user_count={self.user_count} " f"creator_id={self.creator_id}>" + f"channel_id={self.channel_id}>" ) @property @@ -544,14 +473,14 @@ async def edit( reason: str | None = None, name: str = MISSING, description: str = MISSING, - status: int | ScheduledEventStatus = MISSING, + status: ScheduledEventStatus = MISSING, entity_type: ScheduledEventEntityType = MISSING, scheduled_start_time: datetime.datetime = MISSING, scheduled_end_time: datetime.datetime = MISSING, image: bytes | None = MISSING, privacy_level: ScheduledEventPrivacyLevel = MISSING, entity_metadata: ScheduledEventEntityMetadata | None = MISSING, - recurrence_rule: ScheduledEventRecurrenceRule | dict = MISSING, + recurrence_rule: ScheduledEventRecurrenceRule | None = MISSING, ) -> ScheduledEvent | None: """|coro| @@ -625,13 +554,13 @@ async def edit( payload["description"] = description if status is not MISSING: - payload["status"] = int(status) + payload["status"] = int(status.value) if entity_type is not MISSING: payload["entity_type"] = int(entity_type.value) if privacy_level is not MISSING: - payload["privacy_level"] = int(privacy_level) + payload["privacy_level"] = int(privacy_level.value) if entity_metadata is not MISSING: if entity_metadata is None: From 5a2581e07e1fb2f430aaa7c85d6d6bdac38729a2 Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Tue, 9 Dec 2025 21:29:34 +0200 Subject: [PATCH 06/13] feat: enhance scheduled event recurrence with validation and serialization tests --- discord/enums.py | 12 +++ discord/guild.py | 2 +- discord/scheduled_events.py | 186 ++++++++++++++++++++++++++++++------ 3 files changed, 171 insertions(+), 29 deletions(-) diff --git a/discord/enums.py b/discord/enums.py index b9fe919ede..6dfb771f2a 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -966,6 +966,9 @@ class ScheduledEventEntityType(Enum): voice = 2 external = 3 + def __int__(self): + return self.value + class ScheduledEventRecurrenceFrequency(Enum): """Scheduled event recurrence frequency""" @@ -975,6 +978,9 @@ class ScheduledEventRecurrenceFrequency(Enum): weekly = 2 daily = 3 + def __int__(self): + return self.value + class ScheduledEventRecurrenceWeekday(Enum): """Scheduled event recurrence weekday""" @@ -987,6 +993,9 @@ class ScheduledEventRecurrenceWeekday(Enum): saturday = 5 sunday = 6 + def __int__(self): + return self.value + class ScheduledEventRecurrenceMonth(Enum): """Scheduled event recurrence month""" @@ -1004,6 +1013,9 @@ class ScheduledEventRecurrenceMonth(Enum): november = 11 december = 12 + def __int__(self): + return self.value + class AutoModTriggerType(Enum): """Automod trigger type""" diff --git a/discord/guild.py b/discord/guild.py index 20a96c059a..af7405bea2 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -4285,7 +4285,7 @@ async def create_scheduled_event( "name": name, "scheduled_start_time": scheduled_start_time.isoformat(), "privacy_level": int(privacy_level), - "entity_type": int(entity_type.value), + "entity_type": int(entity_type), } if scheduled_end_time is not MISSING: diff --git a/discord/scheduled_events.py b/discord/scheduled_events.py index 83918341c1..64100707dd 100644 --- a/discord/scheduled_events.py +++ b/discord/scheduled_events.py @@ -118,14 +118,14 @@ class ScheduledEventRecurrenceNWeekday: def __init__(self, *, n: int, day: ScheduledEventRecurrenceWeekday | int) -> None: self.n: int = n self.day: ScheduledEventRecurrenceWeekday = try_enum( - ScheduledEventRecurrenceWeekday, day + ScheduledEventRecurrenceWeekday, int(day) ) def __repr__(self) -> str: return f"" def to_payload(self) -> dict[str, int]: - return {"n": int(self.n), "day": int(self.day.value)} + return {"n": int(self.n), "day": int(self.day)} class ScheduledEventRecurrenceRule: @@ -174,12 +174,11 @@ def __init__( self, *, start: datetime.datetime, - frequency: ScheduledEventRecurrenceFrequency, + frequency: ScheduledEventRecurrenceFrequency | int, interval: int, end: datetime.datetime | None = None, by_weekday: list[ScheduledEventRecurrenceWeekday | int] | None = None, - by_n_weekday: list[ScheduledEventRecurrenceNWeekday | dict[str, int]] - | None = None, + by_n_weekday: list[ScheduledEventRecurrenceNWeekday] | None = None, by_month: list[ScheduledEventRecurrenceMonth | int] | None = None, by_month_day: list[int] | None = None, by_year_day: list[int] | None = None, @@ -187,25 +186,19 @@ def __init__( ) -> None: self.start: datetime.datetime = start self.end: datetime.datetime | None = end - self.frequency: ScheduledEventRecurrenceFrequency = frequency + self.frequency: ScheduledEventRecurrenceFrequency = try_enum( + ScheduledEventRecurrenceFrequency, int(frequency) + ) self.interval: int = interval self.by_weekday: list[ScheduledEventRecurrenceWeekday] | None = ( - [try_enum(ScheduledEventRecurrenceWeekday, day) for day in by_weekday] - if by_weekday is not None + [try_enum(ScheduledEventRecurrenceWeekday, int(day)) for day in by_weekday] + if by_weekday else None ) - if by_n_weekday is not None: - self.by_n_weekday = [ - entry - if isinstance(entry, ScheduledEventRecurrenceNWeekday) - else ScheduledEventRecurrenceNWeekday(**entry) - for entry in by_n_weekday - ] - else: - self.by_n_weekday = None + self.by_n_weekday: list[ScheduledEventRecurrenceNWeekday] | None = by_n_weekday self.by_month: list[ScheduledEventRecurrenceMonth] | None = ( - [try_enum(ScheduledEventRecurrenceMonth, month) for month in by_month] - if by_month is not None + [try_enum(ScheduledEventRecurrenceMonth, int(month)) for month in by_month] + if by_month else None ) self.by_month_day: list[int] | None = by_month_day @@ -224,7 +217,13 @@ def from_data( ) -> ScheduledEventRecurrenceRule: start = utils.parse_time(data["start"]) end = utils.parse_time(data.get("end")) - by_weekday = data.get("by_weekday") + + raw_by_weekday = data.get("by_weekday") + by_weekday = ( + [try_enum(ScheduledEventRecurrenceWeekday, day) for day in raw_by_weekday] + if raw_by_weekday + else None + ) raw_by_n_weekday = data.get("by_n_weekday") by_n_weekday = ( @@ -233,23 +232,44 @@ def from_data( else None ) + raw_by_month = data.get("by_month") + by_month = ( + [try_enum(ScheduledEventRecurrenceMonth, month) for month in raw_by_month] + if raw_by_month + else None + ) + return cls( start=start, end=end, - frequency=data["frequency"], + frequency=try_enum(ScheduledEventRecurrenceFrequency, data["frequency"]), interval=data["interval"], by_weekday=by_weekday, by_n_weekday=by_n_weekday, - by_month=data.get("by_month"), + by_month=by_month, by_month_day=data.get("by_month_day"), by_year_day=data.get("by_year_day"), count=data.get("count"), ) def to_payload(self) -> dict[str, Any]: + """Convert the recurrence rule to an API payload. + + Raises + ------ + ValidationError + If the recurrence rule violates Discord's system limitations. + + Returns + ------- + dict[str, Any] + The recurrence rule as a dictionary suitable for the Discord API. + """ + self.validate() + payload: dict[str, Any] = { "start": self.start.isoformat(), - "frequency": int(self.frequency.value), + "frequency": self.frequency.value, "interval": int(self.interval), } @@ -257,7 +277,7 @@ def to_payload(self) -> dict[str, Any]: payload["end"] = self.end.isoformat() if self.by_weekday is not None: - payload["by_weekday"] = [int(day.value) for day in self.by_weekday] + payload["by_weekday"] = [int(day) for day in self.by_weekday] if self.by_n_weekday is not None: payload["by_n_weekday"] = [ @@ -265,7 +285,7 @@ def to_payload(self) -> dict[str, Any]: ] if self.by_month is not None: - payload["by_month"] = [int(month.value) for month in self.by_month] + payload["by_month"] = [int(month) for month in self.by_month] if self.by_month_day is not None: payload["by_month_day"] = self.by_month_day @@ -278,6 +298,116 @@ def to_payload(self) -> dict[str, Any]: return payload + def validate(self) -> None: + """Validate the recurrence rule against Discord's system limitations. + + Raises + ------ + ValidationError + If the recurrence rule violates any system limitations. + """ + # Mutually exclusive combinations + has_by_weekday = self.by_weekday is not None + has_by_n_weekday = self.by_n_weekday is not None + has_by_month = self.by_month is not None + has_by_month_day = self.by_month_day is not None + + if has_by_weekday and has_by_n_weekday: + raise ValidationError("by_weekday and by_n_weekday are mutually exclusive") + + if has_by_month and has_by_n_weekday: + raise ValidationError("by_month and by_n_weekday are mutually exclusive") + + if has_by_month != has_by_month_day: + raise ValidationError( + "by_month and by_month_day must both be provided together" + ) + + # Daily frequency (0) constraints + if self.frequency == ScheduledEventRecurrenceFrequency.yearly: + if has_by_weekday: + raise ValidationError("by_weekday is not valid for yearly events") + + # Weekly frequency (2) constraints + if self.frequency == ScheduledEventRecurrenceFrequency.weekly: + if has_by_weekday: + if len(self.by_weekday) != 1: + raise ValidationError( + "by_weekday must have exactly 1 day for weekly events" + ) + + if has_by_n_weekday: + raise ValidationError("by_n_weekday is not valid for weekly events") + + if has_by_month or has_by_month_day: + raise ValidationError( + "by_month and by_month_day are not valid for weekly events" + ) + + # interval can only be 2 (every-other week) or 1 (weekly) + if self.interval not in (1, 2): + raise ValidationError("interval for weekly events can only be 1 or 2") + + # Daily frequency (3) constraints + if self.frequency == ScheduledEventRecurrenceFrequency.daily: + if has_by_n_weekday: + raise ValidationError("by_n_weekday is not valid for daily events") + + if has_by_month or has_by_month_day: + raise ValidationError( + "by_month and by_month_day are not valid for daily events" + ) + + if has_by_weekday: + # Validate known sets of weekdays for daily events + allowed_sets = [ + [0, 1, 2, 3, 4], # Monday - Friday + [1, 2, 3, 4, 5], # Tuesday - Saturday + [6, 0, 1, 2, 3], # Sunday - Thursday + [4, 5], # Friday & Saturday + [5, 6], # Saturday & Sunday + [6, 0], # Sunday & Monday + ] + weekday_values = [day.value for day in self.by_weekday] + weekday_values.sort() + + if weekday_values not in allowed_sets: + raise ValidationError( + "by_weekday for daily events must be one of the allowed sets: " + "[0,1,2,3,4], [1,2,3,4,5], [6,0,1,2,3], [4,5], [5,6], [6,0]" + ) + + # Monthly frequency (1) constraints + if self.frequency == ScheduledEventRecurrenceFrequency.monthly: + if has_by_n_weekday: + if len(self.by_n_weekday) != 1: + raise ValidationError( + "by_n_weekday must have exactly 1 entry for monthly events" + ) + + if has_by_weekday: + raise ValidationError("by_weekday is not valid for monthly events") + + if has_by_month or has_by_month_day: + raise ValidationError( + "by_month and by_month_day are not valid for monthly events" + ) + + # Yearly frequency (0) constraints + if self.frequency == ScheduledEventRecurrenceFrequency.yearly: + if has_by_n_weekday: + raise ValidationError("by_n_weekday is not valid for yearly events") + + if not (has_by_month and has_by_month_day): + raise ValidationError( + "by_month and by_month_day must both be provided for yearly events" + ) + + if len(self.by_month) != 1 or len(self.by_month_day) != 1: + raise ValidationError( + "by_month and by_month_day must each have exactly 1 entry for yearly events" + ) + class ScheduledEvent(Hashable): """Represents a Discord Guild Scheduled Event. @@ -554,13 +684,13 @@ async def edit( payload["description"] = description if status is not MISSING: - payload["status"] = int(status.value) + payload["status"] = int(status) if entity_type is not MISSING: - payload["entity_type"] = int(entity_type.value) + payload["entity_type"] = int(entity_type) if privacy_level is not MISSING: - payload["privacy_level"] = int(privacy_level.value) + payload["privacy_level"] = int(privacy_level) if entity_metadata is not MISSING: if entity_metadata is None: From 9d1d5d7fb52863726715921c6c6b5e8078f25aaa Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 19:51:55 +0000 Subject: [PATCH 07/13] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/scheduled_events.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/discord/scheduled_events.py b/discord/scheduled_events.py index 64100707dd..ac1138db08 100644 --- a/discord/scheduled_events.py +++ b/discord/scheduled_events.py @@ -32,10 +32,10 @@ from .enums import ( ScheduledEventEntityType, ScheduledEventPrivacyLevel, - ScheduledEventStatus, ScheduledEventRecurrenceFrequency, ScheduledEventRecurrenceMonth, ScheduledEventRecurrenceWeekday, + ScheduledEventStatus, try_enum, ) from .errors import ValidationError @@ -55,8 +55,8 @@ from .guild import Guild from .member import Member from .state import ConnectionState + from .types.scheduled_events import ScheduledEvent as ScheduledEventPayload from .types.scheduled_events import ( - ScheduledEvent as ScheduledEventPayload, ScheduledEventRecurrenceRule as ScheduledEventRecurrenceRulePayload, ) @@ -255,15 +255,15 @@ def from_data( def to_payload(self) -> dict[str, Any]: """Convert the recurrence rule to an API payload. - Raises - ------ - ValidationError - If the recurrence rule violates Discord's system limitations. - Returns ------- dict[str, Any] The recurrence rule as a dictionary suitable for the Discord API. + + Raises + ------ + ValidationError + If the recurrence rule violates Discord's system limitations. """ self.validate() From 8c85a05f17ef1aee8396e0b57b90db138fc33799 Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Wed, 10 Dec 2025 13:39:25 +0200 Subject: [PATCH 08/13] revert breaking change --- discord/enums.py | 7 +- discord/scheduled_events.py | 149 +++++++++++++++++++++++++++++++++++- 2 files changed, 153 insertions(+), 3 deletions(-) diff --git a/discord/enums.py b/discord/enums.py index 6dfb771f2a..f48d863a2e 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -30,6 +30,7 @@ from enum import IntEnum from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, Union + __all__ = ( "Enum", "ChannelType", @@ -62,7 +63,7 @@ "ScheduledEventStatus", "ScheduledEventPrivacyLevel", "ScheduledEventEntityType", - "ScheduledEventEntityType", + "ScheduledEventLocationType", "ScheduledEventRecurrenceFrequency", "ScheduledEventRecurrenceWeekday", "ScheduledEventRecurrenceMonth", @@ -970,6 +971,10 @@ def __int__(self): return self.value +class ScheduledEventLocationType(ScheduledEventEntityType): + """Scheduled event location type (deprecated alias for ScheduledEventEntityType)""" + + class ScheduledEventRecurrenceFrequency(Enum): """Scheduled event recurrence frequency""" diff --git a/discord/scheduled_events.py b/discord/scheduled_events.py index ac1138db08..e8fb5156a1 100644 --- a/discord/scheduled_events.py +++ b/discord/scheduled_events.py @@ -41,10 +41,12 @@ from .errors import ValidationError from .iterators import ScheduledEventSubscribersIterator from .mixins import Hashable -from .utils import warn_deprecated +from .object import Object +from .utils import warn_deprecated, deprecated __all__ = ( "ScheduledEvent", + "ScheduledEventLocation", "ScheduledEventEntityMetadata", "ScheduledEventRecurrenceRule", "ScheduledEventRecurrenceNWeekday", @@ -52,6 +54,7 @@ if TYPE_CHECKING: from .abc import Snowflake + from .channel import StageChannel, VoiceChannel from .guild import Guild from .member import Member from .state import ConnectionState @@ -59,10 +62,83 @@ from .types.scheduled_events import ( ScheduledEventRecurrenceRule as ScheduledEventRecurrenceRulePayload, ) +else: + ConnectionState = None + StageChannel = None + VoiceChannel = None MISSING = utils.MISSING +class ScheduledEventLocation: + """Represents a scheduled event's location. + + Setting the ``value`` to its corresponding type will set the location type automatically: + + +------------------------+-----------------------------------------------+ + | Type of Input | Location Type | + +========================+===============================================+ + | :class:`StageChannel` | :attr:`ScheduledEventEntityType.stage_instance` | + | :class:`VoiceChannel` | :attr:`ScheduledEventEntityType.voice` | + | :class:`str` | :attr:`ScheduledEventEntityType.external` | + +------------------------+-----------------------------------------------+ + + .. deprecated:: 2.7 + 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:`ScheduledEventEntityType` + The type of location. + """ + + __slots__ = ( + "_state", + "value", + ) + + def __init__( + self, + *, + state: ConnectionState | None = None, + value: str | int | StageChannel | VoiceChannel | None = None, + ) -> None: + warn_deprecated("ScheduledEventLocation", "ScheduledEventEntityMetadata", "2.7") + 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 + + def __repr__(self) -> str: + return f"" + + def __str__(self) -> str: + return str(self.value) if self.value else "" + + @property + def type(self) -> ScheduledEventEntityType: + """The type of location.""" + if isinstance(self.value, str): + return ScheduledEventEntityType.external + elif self.value.__class__.__name__ == "StageChannel": + return ScheduledEventEntityType.stage_instance + elif self.value.__class__.__name__ == "VoiceChannel": + return ScheduledEventEntityType.voice + return ScheduledEventEntityType.voice + + class ScheduledEventEntityMetadata: """Represents a scheduled event's entity metadata. @@ -477,7 +553,6 @@ class ScheduledEvent(Hashable): "status", "creator_id", "creator", - "location", "guild", "_state", "_image", @@ -560,11 +635,59 @@ def __repr__(self) -> str: f"channel_id={self.channel_id}>" ) + @property + @deprecated(instead="entity_metadata.location", since="2.7", removed="3.0") + def location(self) -> ScheduledEventLocation | None: + """ + Returns the location of the event. + """ + if self.channel_id is None: + self.location = ScheduledEventLocation( + state=self._state, value=self.entity_metadata.location + ) + else: + self.location = ScheduledEventLocation( + state=self._state, value=self.channel_id + ) + @property def created_at(self) -> datetime.datetime: """Returns the scheduled event's creation time in UTC.""" return utils.snowflake_time(self.id) + @property + @deprecated(instead="scheduled_start_time", since="2.7", removed="3.0") + def start_time(self) -> datetime.datetime: + """ + Returns the scheduled start time of the event. + + .. deprecated:: 2.7 + Use :attr:`scheduled_start_time` instead. + """ + return self.scheduled_start_time + + @property + @deprecated(instead="scheduled_end_time", since="2.7", removed="3.0") + def end_time(self) -> datetime.datetime | None: + """ + Returns the scheduled end time of the event. + + .. deprecated:: 2.7 + Use :attr:`scheduled_end_time` instead. + """ + return self.scheduled_end_time + + @property + @deprecated(instead="user_count", since="2.7", removed="3.0") + def subscriber_count(self) -> int | None: + """ + Returns the number of users subscribed to the event. + + .. deprecated:: 2.7 + Use :attr:`user_count` instead. + """ + return self.user_count + @property def interested(self) -> int | None: """An alias to :attr:`.user_count`""" @@ -604,10 +727,14 @@ async def edit( name: str = MISSING, description: str = MISSING, status: ScheduledEventStatus = MISSING, + location: ( + str | int | VoiceChannel | StageChannel | ScheduledEventLocation + ) = MISSING, entity_type: ScheduledEventEntityType = 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_metadata: ScheduledEventEntityMetadata | None = MISSING, recurrence_rule: ScheduledEventRecurrenceRule | None = MISSING, @@ -659,6 +786,11 @@ async def edit( 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 ``image`` instead. Returns ------- @@ -704,6 +836,19 @@ async def edit( else: payload["recurrence_rule"] = recurrence_rule + if cover is not MISSING: + warn_deprecated("cover", "image", "2.7", "3.0") + if image is MISSING: + image = cover + + if location is not MISSING: + warn_deprecated("location", "entity_metadata", "2.7", "3.0") + if entity_metadata is MISSING: + if not isinstance(location, (ScheduledEventLocation)): + location = ScheduledEventLocation(state=self._state, value=location) + if location.type == ScheduledEventEntityType.external: + entity_metadata = ScheduledEventEntityMetadata(str(location)) + if image is not MISSING: if image is None: payload["image"] = None From 40d79b6564eb373b55b3a531b02f83f33fe58cc9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 11:39:57 +0000 Subject: [PATCH 09/13] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/enums.py | 1 - discord/scheduled_events.py | 6 ++---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/discord/enums.py b/discord/enums.py index f48d863a2e..85315040d8 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -30,7 +30,6 @@ from enum import IntEnum from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, Union - __all__ = ( "Enum", "ChannelType", diff --git a/discord/scheduled_events.py b/discord/scheduled_events.py index e8fb5156a1..8a09f01c45 100644 --- a/discord/scheduled_events.py +++ b/discord/scheduled_events.py @@ -42,7 +42,7 @@ from .iterators import ScheduledEventSubscribersIterator from .mixins import Hashable from .object import Object -from .utils import warn_deprecated, deprecated +from .utils import deprecated, warn_deprecated __all__ = ( "ScheduledEvent", @@ -638,9 +638,7 @@ def __repr__(self) -> str: @property @deprecated(instead="entity_metadata.location", since="2.7", removed="3.0") def location(self) -> ScheduledEventLocation | None: - """ - Returns the location of the event. - """ + """Returns the location of the event.""" if self.channel_id is None: self.location = ScheduledEventLocation( state=self._state, value=self.entity_metadata.location From b5dc3e9171473eeb86695eb6fa9aaa579e166b19 Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Fri, 12 Dec 2025 07:39:55 +0100 Subject: [PATCH 10/13] Update discord/enums.py Co-authored-by: Paillat Signed-off-by: Lumouille <144063653+Lumabots@users.noreply.github.com> --- discord/enums.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/enums.py b/discord/enums.py index 85315040d8..7bceb5619e 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -971,7 +971,7 @@ def __int__(self): class ScheduledEventLocationType(ScheduledEventEntityType): - """Scheduled event location type (deprecated alias for ScheduledEventEntityType)""" + """Scheduled event location type (deprecated alias for :class:`ScheduledEventEntityType`)""" class ScheduledEventRecurrenceFrequency(Enum): From 13420ce98d0875fb264f0862a612b45483c43639 Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Fri, 12 Dec 2025 12:20:44 +0200 Subject: [PATCH 11/13] paillat comment --- discord/enums.py | 10 ++++++++++ discord/scheduled_events.py | 17 +++++++---------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/discord/enums.py b/discord/enums.py index 7bceb5619e..e17a8823c5 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -1021,6 +1021,16 @@ def __int__(self): return self.value +class ScheduledEventRecurrenceInterval(Enum): + """Scheduled event recurrence interval spacing""" + + single = 1 + every_other = 2 + + def __int__(self): + return self.value + + class AutoModTriggerType(Enum): """Automod trigger type""" diff --git a/discord/scheduled_events.py b/discord/scheduled_events.py index 8a09f01c45..b25c27c448 100644 --- a/discord/scheduled_events.py +++ b/discord/scheduled_events.py @@ -33,6 +33,7 @@ ScheduledEventEntityType, ScheduledEventPrivacyLevel, ScheduledEventRecurrenceFrequency, + ScheduledEventRecurrenceInterval, ScheduledEventRecurrenceMonth, ScheduledEventRecurrenceWeekday, ScheduledEventStatus, @@ -217,7 +218,7 @@ class ScheduledEventRecurrenceRule: Ending time of the recurrence interval. frequency: :class:`ScheduledEventRecurrenceFrequency` How often the event occurs. - interval: :class:`int` + interval: :class:`ScheduledEventRecurrenceInterval` The spacing between events for the given frequency. by_weekday: Optional[list[:class:`ScheduledEventRecurrenceWeekday`]] Specific days within a week for the event to recur on. @@ -251,7 +252,7 @@ def __init__( *, start: datetime.datetime, frequency: ScheduledEventRecurrenceFrequency | int, - interval: int, + interval: ScheduledEventRecurrenceInterval | int, end: datetime.datetime | None = None, by_weekday: list[ScheduledEventRecurrenceWeekday | int] | None = None, by_n_weekday: list[ScheduledEventRecurrenceNWeekday] | None = None, @@ -265,7 +266,9 @@ def __init__( self.frequency: ScheduledEventRecurrenceFrequency = try_enum( ScheduledEventRecurrenceFrequency, int(frequency) ) - self.interval: int = interval + self.interval: ScheduledEventRecurrenceInterval = try_enum( + ScheduledEventRecurrenceInterval, int(interval) + ) self.by_weekday: list[ScheduledEventRecurrenceWeekday] | None = ( [try_enum(ScheduledEventRecurrenceWeekday, int(day)) for day in by_weekday] if by_weekday @@ -319,7 +322,7 @@ def from_data( start=start, end=end, frequency=try_enum(ScheduledEventRecurrenceFrequency, data["frequency"]), - interval=data["interval"], + interval=try_enum(ScheduledEventRecurrenceInterval, data["interval"]), by_weekday=by_weekday, by_n_weekday=by_n_weekday, by_month=by_month, @@ -784,12 +787,6 @@ async def edit( 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 ``image`` instead. - Returns ------- Optional[:class:`.ScheduledEvent`] From 469a7b53ae5afb0cca9009e0695d18c85dbaf535 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 12 Dec 2025 10:21:35 +0000 Subject: [PATCH 12/13] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/scheduled_events.py | 1 + 1 file changed, 1 insertion(+) diff --git a/discord/scheduled_events.py b/discord/scheduled_events.py index b25c27c448..814a2c37c9 100644 --- a/discord/scheduled_events.py +++ b/discord/scheduled_events.py @@ -787,6 +787,7 @@ async def edit( The reason to show in the audit log. image: Optional[:class:`bytes`] The cover image of the scheduled event. + Returns ------- Optional[:class:`.ScheduledEvent`] From a2f09fe2c253e389469fb184809178a33c47e89a Mon Sep 17 00:00:00 2001 From: Lumouille <144063653+Lumabots@users.noreply.github.com> Date: Fri, 12 Dec 2025 14:19:22 +0200 Subject: [PATCH 13/13] feat: add overloads for ScheduledEventRecurrenceRule constructor --- discord/scheduled_events.py | 50 ++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/discord/scheduled_events.py b/discord/scheduled_events.py index 814a2c37c9..955177f26f 100644 --- a/discord/scheduled_events.py +++ b/discord/scheduled_events.py @@ -25,7 +25,7 @@ from __future__ import annotations import datetime -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, overload from . import utils from .asset import Asset @@ -247,6 +247,54 @@ class ScheduledEventRecurrenceRule: "count", ) + @overload + def __init__( + self, + *, + start: datetime.datetime, + frequency: ScheduledEventRecurrenceFrequency | int, + interval: ScheduledEventRecurrenceInterval | int, + end: datetime.datetime | None = None, + by_weekday: list[ScheduledEventRecurrenceWeekday | int], + by_n_weekday: None = None, + by_month: None = None, + by_month_day: None = None, + by_year_day: list[int] | None = None, + count: int | None = None, + ) -> None: ... + + @overload + def __init__( + self, + *, + start: datetime.datetime, + frequency: ScheduledEventRecurrenceFrequency | int, + interval: ScheduledEventRecurrenceInterval | int, + end: datetime.datetime | None = None, + by_weekday: None = None, + by_n_weekday: list[ScheduledEventRecurrenceNWeekday], + by_month: None = None, + by_month_day: None = None, + by_year_day: list[int] | None = None, + count: int | None = None, + ) -> None: ... + + @overload + def __init__( + self, + *, + start: datetime.datetime, + frequency: ScheduledEventRecurrenceFrequency | int, + interval: ScheduledEventRecurrenceInterval | int, + end: datetime.datetime | None = None, + by_weekday: None = None, + by_n_weekday: None = None, + by_month: list[ScheduledEventRecurrenceMonth | int], + by_month_day: list[int], + by_year_day: list[int] | None = None, + count: int | None = None, + ) -> None: ... + def __init__( self, *,