diff --git a/discord/audit_logs.py b/discord/audit_logs.py index b2f6a72393..9a00f15715 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), @@ -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: @@ -372,21 +365,6 @@ 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.after, attr, after) - # add an alias if hasattr(self.after, "colour"): self.after.color = self.after.colour @@ -691,7 +669,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 63557c853b..e17a8823c5 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -61,7 +61,11 @@ "EmbeddedActivity", "ScheduledEventStatus", "ScheduledEventPrivacyLevel", + "ScheduledEventEntityType", "ScheduledEventLocationType", + "ScheduledEventRecurrenceFrequency", + "ScheduledEventRecurrenceWeekday", + "ScheduledEventRecurrenceMonth", "InputTextStyle", "SlashCommandOptionType", "AutoModTriggerType", @@ -955,13 +959,77 @@ 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 + + +class ScheduledEventLocationType(ScheduledEventEntityType): + """Scheduled event location type (deprecated alias for :class:`ScheduledEventEntityType`)""" + + +class ScheduledEventRecurrenceFrequency(Enum): + """Scheduled event recurrence frequency""" + + yearly = 0 + monthly = 1 + weekly = 2 + daily = 3 + + def __int__(self): + return self.value + + +class ScheduledEventRecurrenceWeekday(Enum): + """Scheduled event recurrence weekday""" + + monday = 0 + tuesday = 1 + wednesday = 2 + thursday = 3 + friday = 4 + saturday = 5 + sunday = 6 + + def __int__(self): + return self.value + + +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 + + 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/guild.py b/discord/guild.py index f910affe5c..af7405bea2 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,11 @@ 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, + ScheduledEventRecurrenceRule, +) from .soundboard import SoundboardSound from .stage_instance import StageInstance from .sticker import GuildSticker @@ -4213,36 +4223,49 @@ async def create_scheduled_event( *, name: str, description: str = MISSING, - start_time: datetime.datetime, - end_time: datetime.datetime = MISSING, - location: str | int | VoiceChannel | StageChannel | ScheduledEventLocation, + scheduled_start_time: datetime.datetime, + scheduled_end_time: datetime.datetime = MISSING, + 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: ScheduledEventRecurrenceRule | None = 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`] The cover image of the scheduled event + recurrence_rule: Optional[Union[:class:`ScheduledEventRecurrenceRule`, :class:`dict`]] + The definition for how often this event should recur. Returns ------- @@ -4255,34 +4278,55 @@ 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(), + "scheduled_start_time": scheduled_start_time.isoformat(), "privacy_level": int(privacy_level), + "entity_type": int(entity_type), } - 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 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 recurrence_rule is not MISSING: + 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: + 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/http.py b/discord/http.py index ae64703ba6..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} @@ -2467,6 +2468,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..955177f26f 100644 --- a/discord/scheduled_events.py +++ b/discord/scheduled_events.py @@ -25,35 +25,48 @@ 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 from .enums import ( - ScheduledEventLocationType, + ScheduledEventEntityType, ScheduledEventPrivacyLevel, + ScheduledEventRecurrenceFrequency, + ScheduledEventRecurrenceInterval, + ScheduledEventRecurrenceMonth, + ScheduledEventRecurrenceWeekday, ScheduledEventStatus, try_enum, ) -from .errors import InvalidArgument, ValidationError +from .errors import ValidationError from .iterators import ScheduledEventSubscribersIterator from .mixins import Hashable from .object import Object -from .utils import warn_deprecated +from .utils import deprecated, warn_deprecated __all__ = ( "ScheduledEvent", "ScheduledEventLocation", + "ScheduledEventEntityMetadata", + "ScheduledEventRecurrenceRule", + "ScheduledEventRecurrenceNWeekday", ) 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 + from .types.scheduled_events import ( + ScheduledEventRecurrenceRule as ScheduledEventRecurrenceRulePayload, + ) +else: + ConnectionState = None + StageChannel = None + VoiceChannel = None MISSING = utils.MISSING @@ -63,13 +76,16 @@ class ScheduledEventLocation: 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` | - +------------------------+---------------------------------------------------+ + +------------------------+-----------------------------------------------+ + | 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 @@ -77,7 +93,7 @@ class ScheduledEventLocation: ---------- 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 +105,20 @@ 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, + ) -> 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 @@ -103,16 +126,414 @@ 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.7 + + 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]: + """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 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, int(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:`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. + 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", + ) + + @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, + *, + start: datetime.datetime, + frequency: ScheduledEventRecurrenceFrequency | int, + interval: ScheduledEventRecurrenceInterval | int, + end: datetime.datetime | None = None, + by_weekday: list[ScheduledEventRecurrenceWeekday | 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, + count: int | None = None, + ) -> None: + self.start: datetime.datetime = start + self.end: datetime.datetime | None = end + self.frequency: ScheduledEventRecurrenceFrequency = try_enum( + ScheduledEventRecurrenceFrequency, int(frequency) + ) + 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 + else None + ) + self.by_n_weekday: list[ScheduledEventRecurrenceNWeekday] | None = by_n_weekday + self.by_month: list[ScheduledEventRecurrenceMonth] | 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 + 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")) + + 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 = ( + [ScheduledEventRecurrenceNWeekday(**entry) for entry in raw_by_n_weekday] + if raw_by_n_weekday + 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=try_enum(ScheduledEventRecurrenceFrequency, data["frequency"]), + interval=try_enum(ScheduledEventRecurrenceInterval, data["interval"]), + by_weekday=by_weekday, + by_n_weekday=by_n_weekday, + 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. + + 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() + + payload: dict[str, Any] = { + "start": self.start.isoformat(), + "frequency": self.frequency.value, + "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 + + 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): @@ -146,16 +567,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 +585,36 @@ 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:`ScheduledEventRecurrenceRule`] + The definition for how often this event should recur. """ __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", + "recurrence_rule", + "channel_id", + "entity_id", + "entity_metadata", ) def __init__( @@ -200,27 +632,42 @@ 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") + ) + 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") + + 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: 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: - self.location = ScheduledEventLocation( - state=state, value=entity_metadata["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 @@ -230,23 +677,70 @@ def __repr__(self) -> str: f"" + 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:`.subscriber_count`""" - return self.subscriber_count + """An alias to :attr:`.user_count`""" + return self.user_count @property def url(self) -> str: @@ -281,55 +775,66 @@ 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, - start_time: datetime.datetime = MISSING, - end_time: datetime.datetime = MISSING, - cover: bytes | None = MISSING, + entity_type: ScheduledEventEntityType = MISSING, + scheduled_start_time: datetime.datetime = MISSING, + scheduled_end_time: datetime.datetime = MISSING, image: bytes | None = MISSING, - privacy_level: ScheduledEventPrivacyLevel = ScheduledEventPrivacyLevel.guild_only, + cover: bytes | None = MISSING, + privacy_level: ScheduledEventPrivacyLevel = MISSING, + entity_metadata: ScheduledEventEntityMetadata | None = MISSING, + recurrence_rule: ScheduledEventRecurrenceRule | None = 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. - 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. + 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``. + 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_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: 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. 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 +848,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,60 +862,78 @@ 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: + payload["entity_metadata"] = entity_metadata.to_payload() + + if recurrence_rule is not MISSING: + if isinstance(recurrence_rule, ScheduledEventRecurrenceRule): + payload["recurrence_rule"] = recurrence_rule.to_payload() + 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 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( - "end_time needs to be passed if location type is external." + "entity_metadata with a location is required when entity_type is EXTERNAL." + ) + if not entity_metadata.location: + raise ValidationError( + "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( - 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 +1040,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 +1068,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 +1102,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: diff --git a/discord/types/scheduled_events.py b/discord/types/scheduled_events.py index 9bb4ad0328..c86b6b02f5 100644 --- a/discord/types/scheduled_events.py +++ b/discord/types/scheduled_events.py @@ -31,8 +31,42 @@ from .user import User ScheduledEventStatus = Literal[1, 2, 3, 4] -ScheduledEventLocationType = Literal[1, 2, 3] +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): @@ -47,11 +81,12 @@ 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 user_count: int | None + recurrence_rule: ScheduledEventRecurrenceRule | None class ScheduledEventEntityMetadata(TypedDict):