diff --git a/CHANGELOG.md b/CHANGELOG.md index 38725e4141..4a0a8170e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,8 @@ These changes are available on the `master` branch, but have not yet been releas ([#2714](https://github.com/Pycord-Development/pycord/pull/2714)) - Added the ability to pass a `datetime.time` object to `format_dt` ([#2747](https://github.com/Pycord-Development/pycord/pull/2747)) +- Added support getting and setting recurrence rules on `ScheduledEvent`s. + ([#2749](https://github.com/Pycord-Development/pycord/pull/2749)) ### Fixed diff --git a/discord/enums.py b/discord/enums.py index e1086651e9..3c6867b14c 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -76,6 +76,8 @@ "EntitlementOwnerType", "IntegrationType", "InteractionContextType", + "ScheduledEventRecurrenceFrequency", + "ScheduledEventWeekday", ) @@ -1063,6 +1065,27 @@ class SubscriptionStatus(Enum): inactive = 2 +class ScheduledEventRecurrenceFrequency(Enum): + """A scheduled event recurrence rule's frequency.""" + + yearly = 0 + monthly = 1 + weekly = 2 + daily = 3 + + +class ScheduledEventWeekday(Enum): + """A scheduled event week day.""" + + monday = 0 + tuesday = 1 + wednesday = 2 + thursday = 3 + friday = 4 + saturday = 5 + sunday = 6 + + T = TypeVar("T") diff --git a/discord/guild.py b/discord/guild.py index 337abd31c0..74e3d1395c 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -80,7 +80,11 @@ from .onboarding import Onboarding from .permissions import PermissionOverwrite from .role import Role -from .scheduled_events import ScheduledEvent, ScheduledEventLocation +from .scheduled_events import ( + ScheduledEvent, + ScheduledEventLocation, + ScheduledEventRecurrenceRule, +) from .stage_instance import StageInstance from .sticker import GuildSticker from .threads import Thread, ThreadMember @@ -3770,14 +3774,16 @@ async def create_scheduled_event( *, name: str, description: str = MISSING, - start_time: datetime, - end_time: datetime = MISSING, + start_time: datetime.datetime, + end_time: datetime.datetime = MISSING, location: str | int | VoiceChannel | StageChannel | ScheduledEventLocation, privacy_level: ScheduledEventPrivacyLevel = ScheduledEventPrivacyLevel.guild_only, reason: str | None = None, image: bytes = MISSING, - ) -> ScheduledEvent | None: + recurrence_rule: ScheduledEventRecurrenceRule | None = MISSING, + ) -> ScheduledEvent: """|coro| + Creates a scheduled event. Parameters @@ -3799,7 +3805,10 @@ async def create_scheduled_event( reason: Optional[:class:`str`] The reason to show in the audit log. image: Optional[:class:`bytes`] - The cover image of the scheduled event + The cover image of the scheduled event. + recurrence_rule: Optional[:class:`ScheduledEventRecurrenceRule`] + The recurrence rule this event will follow. If this is ``None`` then this is a + one-time event. Returns ------- @@ -3813,7 +3822,8 @@ async def create_scheduled_event( HTTPException The operation failed. """ - payload: dict[str, str | int] = { + + payload: dict[str, Any] = { "name": name, "scheduled_start_time": start_time.isoformat(), "privacy_level": int(privacy_level), @@ -3840,6 +3850,12 @@ async def create_scheduled_event( 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_dict() + data = await self._state.http.create_scheduled_event( guild_id=self.id, reason=reason, **payload ) diff --git a/discord/scheduled_events.py b/discord/scheduled_events.py index 7e339dcee7..23b936c55d 100644 --- a/discord/scheduled_events.py +++ b/discord/scheduled_events.py @@ -25,17 +25,19 @@ from __future__ import annotations import datetime -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal from . import utils from .asset import Asset from .enums import ( ScheduledEventLocationType, ScheduledEventPrivacyLevel, + ScheduledEventRecurrenceFrequency, ScheduledEventStatus, + ScheduledEventWeekday, try_enum, ) -from .errors import InvalidArgument, ValidationError +from .errors import ClientException, InvalidArgument, ValidationError from .iterators import ScheduledEventSubscribersIterator from .mixins import Hashable from .object import Object @@ -44,16 +46,25 @@ __all__ = ( "ScheduledEvent", "ScheduledEventLocation", + "ScheduledEventRecurrenceRule", ) if TYPE_CHECKING: + from typing_extensions import Self + 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 from .types.scheduled_events import ScheduledEvent as ScheduledEventPayload + from .types.scheduled_events import ( + ScheduledEventRecurrenceRule as ScheduledEventRecurrenceRulePayload, + ) + + Week = Literal[1, 2, 3, 4, 5] + WeekDay = Literal[0, 1, 2, 3, 4, 5, 6] + NWeekDay = tuple[Week, WeekDay] MISSING = utils.MISSING @@ -115,6 +126,331 @@ def type(self) -> ScheduledEventLocationType: return ScheduledEventLocationType.voice +class ScheduledEventRecurrenceRule: + """Represents a :class:`ScheduledEvent`'s recurrence rule. + + .. versionadded:: 2.7 + + Parameters + ---------- + start_date: :class:`datetime.datetime` + When will this recurrence rule start. + frequency: :class:`ScheduledEventRecurrenceFrequency` + The frequency on which the event will recur. + interval: :class:`int` + The spacing between events, defined by ``frequency``. + Must be ``1`` except if ``frequency`` is :attr:`ScheduledEventRecurrenceFrequency.weekly`, + in which case it can also be ``2``. + weekdays: List[Union[:class:`int`, :class:`ScheduledEventWeekday`]] + The days within a week the event will recur on. Must be between + 0 (Monday) and 6 (Sunday). + If ``frequency`` is ``2`` this can only have 1 item. + This is mutally exclusive with ``n_weekdays`` and ``month_days``. + n_weekdays: List[Tuple[:class:`int`, :class:`int`]] + A (week, weekday) pairs list that represent the specific day within a + specific week the event will recur on. + ``week`` must be between 1 and 5, representing the first and last week of a month + respectively. + ``weekday`` must be an integer between 0 (Monday) and 6 (Sunday). + This is mutually exclusive with ``weekdays`` and ``month_days``. + month_days: List[:class:`datetime.date`] + The specific days and months in which the event will recur on. The year will be ignored. + This is mutually exclusive with ``weekdays`` and ``n_weekdays``. + + Attributes + ---------- + end_date: Optional[:class:`datetime.datetime`] + The date on which this recurrence rule will stop. + count: Optional[:class:`int`] + The amount of times the event will recur before stopping. Will be ``None`` + if :attr:`ScheduledEventRecurrenceRule.end_date` is ``None``. + + Examples + -------- + Creating a recurrence rule that repeats every weekday: :: + rrule = discord.ScheduledEventRecurrenceRule( + start_date=..., + frequency=discord.ScheduledEventRecurrenceFrequency.daily, + interval=1, + weekdays=[0, 1, 2, 3, 4], # from monday to friday + ) + Creating a recurrence rule that repeats every Wednesday: :: + rrule = discord.ScheduledEventRecurrenceRule( + start_date=..., + frequency=discord.ScheduledEventRecurrenceFrequency.weekly, + interval=1, # interval must be 1 for the rule to be "every Wednesday" + weekdays=[2], # wednesday + ) + Creating a recurrence rule that repeats every other Wednesday: :: + rrule = discord.ScheduledEventRecurrenceRule( + start_date=..., + frequency=discord.ScheduledEventRecurrenceFrequency.weekly, + interval=2, # interval CAN ONLY BE 2 in this context, and makes the rule be "every other Wednesday" + weekdays=[2], + ) + Creating a recurrence rule that repeats every month on the fourth Wednesday: :: + rrule = discord.ScheduledEventRecurrenceRule( + start_date=..., + frequency=discord.ScheduledEventRecurrenceFrequency.monthly, + interval=1, + n_weekdays=[ + ( + 4, # fourth week + 2, # wednesday + ), + ], + ) + Creating a recurrence rule that repeats anually on July 4: :: + rrule = discord.ScheduledEventRecurrenceRule( + start_date=..., + frequency=discord.ScheduledEventRecurrenceFrequency.yearly, + month_days=[ + datetime.date( + year=1900, # use a placeholder year, it is ignored anyways + month=7, # July + day=4, # 4th + ), + ], + ) + """ + + __slots__ = ( + "start_date", + "frequency", + "interval", + "count", + "end_date", + "_weekdays", + "_n_weekdays", + "_month_days", + "_year_days", + "_state", + ) + + def __init__( + self, + start_date: datetime.datetime, + frequency: ScheduledEventRecurrenceFrequency, + interval: Literal[1, 2], + *, + weekdays: list[WeekDay | ScheduledEventWeekday] = MISSING, + n_weekdays: list[NWeekDay] = MISSING, + month_days: list[datetime.date] = MISSING, + ) -> None: + self.start_date: datetime.datetime = start_date + self.frequency: ScheduledEventRecurrenceFrequency = frequency + self.interval: Literal[1, 2] = interval + + self.count: int | None = None + self.end_date: datetime.datetime | None = None + + self._weekdays: list[ScheduledEventWeekday] = self._parse_weekdays(weekdays) + self._n_weekdays: list[NWeekDay] = n_weekdays + self._month_days: list[datetime.date] = month_days + self._year_days: list[int] = MISSING + + self._state: ConnectionState | None = None + + def __repr__(self) -> str: + return f"" + + @property + def weekdays(self) -> list[ScheduledEventWeekday]: + """Returns a read-only list containing all the specific days + within a week on which the event will recur on. + """ + if self._weekdays is MISSING: + return [] + return self._weekdays.copy() + + @property + def n_weekdays(self) -> list[NWeekDay]: + """Returns a read-only list containing all the specific days + within a specific week on which the event will recur on. + """ + if self._n_weekdays is MISSING: + return [] + return self._n_weekdays.copy() + + @property + def month_days(self) -> list[datetime.date]: + """Returns a read-only list containing all the specific days + within a specific month on which the event will recur on. + """ + if self._month_days is MISSING: + return [] + return self._month_days.copy() + + @property + def year_days(self) -> list[int]: + """Returns a read-only list containing all the specific days + of the year on which the event will recur on. + """ + if self._year_days is MISSING: + return [] + return self._year_days.copy() + + def copy(self) -> ScheduledEventRecurrenceRule: + """Creates a stateless copy of this object that allows for + methods such as :meth:`.edit` to be used on. + + Returns + ------- + :class:`ScheduledEventRecurrenceRule` + The recurrence rule copy. + """ + + return ScheduledEventRecurrenceRule( + start_date=self.start_date, + frequency=self.frequency, + interval=self.interval, + weekdays=self._weekdays, # pyright: ignore[reportArgumentType] + n_weekdays=self._n_weekdays, + month_days=self._month_days, + ) + + def edit( + self, + *, + weekdays: list[WeekDay | ScheduledEventWeekday] | None = MISSING, + n_weekdays: list[NWeekDay] | None = MISSING, + month_days: list[datetime.date] | None = MISSING, + ) -> Self: + """Edits this recurrence rule. + + If this recurrence rule was obtained from the API you will need to + :meth:`.copy` it in order to edit it. + + Parameters + ---------- + weekdays: List[Union[:class:`int`, :class:`ScheduledEventWeekday`]] + The weekdays the event will recur on. Must be between 0 (Monday) and 6 (Sunday). + n_weekdays: List[Tuple[:class:`int`, :class:`int`]] + A (week, weekday) tuple pair list that represents the specific ``weekday``, from 0 (Monday) + to 6 (Sunday), on ``week`` on which this event will recur on. + month_days: List[:class:`datetime.date`] + A list containing the specific day on a month when the event will recur on. The year + is ignored. + + Returns + ------- + :class:`ScheduledEventRecurrenceRule` + The updated recurrence rule. + + Raises + ------ + ClientException + You cannot edit this recurrence rule. + """ + + if self._state is not None: + raise ClientException("You cannot edit this recurrence rule") + + for value, attr in ( + (weekdays, "_weekdays"), + (n_weekdays, "_n_weekdays"), + (month_days, "_month_days"), + ): + if value is None: + setattr(self, attr, MISSING) + elif value is not MISSING: + if attr == "_weekdays": + value = self._parse_weekdays( + weekdays + ) # pyright: ignore[reportArgumentType] + setattr(self, attr, value) + + return self + + def _get_month_days_payload(self) -> tuple[list[int], list[int]]: + months, days = map(list, zip(*((m.month, m.day) for m in self._month_days))) + return months, days + + def _parse_month_days_payload( + self, months: list[int], days: list[int] + ) -> list[datetime.date]: + return [datetime.date(1, month, day) for month, day in zip(months, days)] + + def _parse_weekdays( + self, weekdays: list[WeekDay | ScheduledEventWeekday] + ) -> list[ScheduledEventWeekday]: + return [ + w if w is ScheduledEventWeekday else try_enum(ScheduledEventWeekday, w) + for w in weekdays + ] + + def _get_weekdays(self) -> list[WeekDay]: + return [w.value for w in self._weekdays] + + @classmethod + def _from_data( + cls, data: ScheduledEventRecurrenceRulePayload | None, state: ConnectionState + ) -> Self | None: + if data is None: + return None + + start = utils.parse_time(data["start"]) + end = utils.parse_time(data.get("end")) + + self = cls( + start_date=start, + frequency=try_enum(ScheduledEventRecurrenceFrequency, data["frequency"]), + interval=int(data["interval"]), # pyright: ignore[reportArgumentType] + ) + + self._state = state + self.end_date = end + self.count = data.get("count") + + weekdays = data.get("by_weekday", MISSING) or MISSING + self._weekdays = self._parse_weekdays( + weekdays + ) # pyright: ignore[reportArgumentType] + + n_weekdays = data.get("by_n_weekday", MISSING) or MISSING + if n_weekdays is not MISSING: + self._n_weekdays = [(n["n"], n["day"]) for n in n_weekdays] + + months = data.get("by_month") + month_days = data.get("by_month_day") + + if months and month_days: + self._month_days = self._parse_month_days_payload(months, month_days) + + year_days = data.get("by_year_day") + if year_days is not None: + self._year_days = year_days + + return self + + def _to_dict(self) -> ScheduledEventRecurrenceRulePayload: + payload: ScheduledEventRecurrenceRulePayload = { + "start": self.start_date.isoformat(), + "frequency": self.frequency.value, + "interval": self.interval, + "by_weekday": None, + "by_n_weekday": None, + "by_month": None, + "by_month_day": None, + } + + if self._weekdays is not MISSING: + payload["by_weekday"] = self._get_weekdays() + if self._n_weekdays is not MISSING: + payload["by_n_weekday"] = list( + map( + lambda nw: {"n": nw[0], "day": nw[1]}, + self._n_weekdays, + ), + ) + if self._month_days is not MISSING: + months, month_days = self._get_month_days_payload() + payload["by_month"] = months + payload["by_month_day"] = month_days + + return payload + + class ScheduledEvent(Hashable): """Represents a Discord Guild Scheduled Event. @@ -167,6 +503,15 @@ 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. + recurrence_rule: Optional[:class:`ScheduledEventRecurrenceRule`] + The recurrence rule this scheduled event follows. + + .. versionadded:: 2.7 + exceptions: List[:class:`Object`] + A list of objects that represents the events on the recurrence rule that were + cancelled or moved out of it. + + .. versionadded:: 2.7 """ __slots__ = ( @@ -183,6 +528,8 @@ class ScheduledEvent(Hashable): "_state", "_image", "subscriber_count", + "recurrence_rule", + "exceptions", ) def __init__( @@ -222,6 +569,19 @@ def __init__( else: self.location = ScheduledEventLocation(state=state, value=int(channel_id)) + self.recurrence_rule: ScheduledEventRecurrenceRule | None = ( + ScheduledEventRecurrenceRule._from_data( + data.get("recurrence_rule"), + state, + ) + ) + self.exceptions: list[Object] = list( + map( + Object, + data.get("guild_scheduled_events_exceptions", []) or [], + ), + ) + def __str__(self) -> str: return self.name @@ -290,6 +650,7 @@ async def edit( cover: bytes | None = MISSING, image: bytes | None = MISSING, privacy_level: ScheduledEventPrivacyLevel = ScheduledEventPrivacyLevel.guild_only, + recurrence_rule: ScheduledEventRecurrenceRule | None = MISSING, ) -> ScheduledEvent | None: """|coro| @@ -330,6 +691,11 @@ async def edit( .. deprecated:: 2.7 Use the `image` argument instead. + recurrence_rule: Optional[:class:`ScheduledEventRecurrenceRule`] + The recurrence rule this event will follow, or ``None`` to set it to a + one-time event. + + .. versionadded:: 2.7 Returns ------- @@ -402,6 +768,12 @@ async def edit( if end_time is not MISSING: payload["scheduled_end_time"] = end_time.isoformat() + if recurrence_rule is not MISSING: + if recurrence_rule is None: + payload["recurrence_rule"] = None + else: + payload["recurrence_rule"] = recurrence_rule._to_dict() + if payload != {}: data = await self._state.http.edit_scheduled_event( self.guild.id, self.id, **payload, reason=reason @@ -452,7 +824,7 @@ async def start(self, *, reason: str | None = None) -> None: """ return await self.edit(status=ScheduledEventStatus.active, reason=reason) - async def complete(self, *, reason: str | None = None) -> None: + async def complete(self, *, reason: str | None = None) -> ScheduledEvent | None: """|coro| Ends/completes the scheduled event. Shortcut from :meth:`.edit`. @@ -480,7 +852,7 @@ async def complete(self, *, reason: str | None = None) -> None: """ return await self.edit(status=ScheduledEventStatus.completed, reason=reason) - async def cancel(self, *, reason: str | None = None) -> None: + async def cancel(self, *, reason: str | None = None) -> ScheduledEvent | None: """|coro| Cancels the scheduled event. Shortcut from :meth:`.edit`. diff --git a/discord/types/scheduled_events.py b/discord/types/scheduled_events.py index 9bb4ad0328..09d9943d69 100644 --- a/discord/types/scheduled_events.py +++ b/discord/types/scheduled_events.py @@ -26,6 +26,8 @@ from typing import Literal, TypedDict +from typing_extensions import NotRequired + from .member import Member from .snowflake import Snowflake from .user import User @@ -33,6 +35,8 @@ ScheduledEventStatus = Literal[1, 2, 3, 4] ScheduledEventLocationType = Literal[1, 2, 3] ScheduledEventPrivacyLevel = Literal[2] +ScheduledEventRecurrenceFrequency = Literal[0, 1, 2, 3] +ScheduledEventWeekdayRecurrence = Literal[0, 1, 2, 3, 4, 5, 6] class ScheduledEvent(TypedDict): @@ -52,6 +56,9 @@ class ScheduledEvent(TypedDict): entity_metadata: ScheduledEventEntityMetadata creator: User user_count: int | None + recurrence_rule: ScheduledEventRecurrenceRule | None + auto_start: bool + guild_scheduled_events_exceptions: list[Snowflake] class ScheduledEventEntityMetadata(TypedDict): @@ -63,3 +70,21 @@ class ScheduledEventSubscriber(TypedDict): user_id: Snowflake user: User member: Member | None + + +class ScheduledEventRecurrenceRule(TypedDict): + start: str + end: NotRequired[str | None] + frequency: ScheduledEventRecurrenceFrequency + interval: int + by_weekday: list[ScheduledEventWeekdayRecurrence] | None + by_n_weekday: list[ScheduledEventNWeekdayRecurrence] | None + by_month: list[int] | None + by_month_day: list[int] | None + by_year_day: NotRequired[list[int] | None] + count: NotRequired[int | None] + + +class ScheduledEventNWeekdayRecurrence(TypedDict): + n: Literal[1, 2, 3, 4, 5] + day: ScheduledEventWeekdayRecurrence diff --git a/docs/api/data_classes.rst b/docs/api/data_classes.rst index 1d891b90cd..ba1f965c53 100644 --- a/docs/api/data_classes.rst +++ b/docs/api/data_classes.rst @@ -126,6 +126,14 @@ Poll .. autoclass:: PollResults :members: +Scheduled Event Recurrence rule +------------------------------- + +.. attributetable:: ScheduledEventRecurrenceRule + +.. autoclass:: ScheduledEventRecurrenceRule + :members: + Flags diff --git a/docs/api/enums.rst b/docs/api/enums.rst index 4d278e3758..2c3ad89cf2 100644 --- a/docs/api/enums.rst +++ b/docs/api/enums.rst @@ -2519,3 +2519,61 @@ of :class:`enum.Enum`. .. attribute:: inactive The subscription is inactive and the subscription owner is not being charged. + + +.. class:: ScheduledEventRecurrenceRuleFrequency + + A scheduled event recurrence rule's frequency. + + .. versionadded:: 2.7 + + .. attribute:: yearly + + The event will repeat on a yearly basis. + + .. attribute:: monthly + + The event will repeat on a monthly basis. + + .. attribute:: weekly + + The event will repeat on a weekly basis. + + .. attribute:: daily + + The event will repeat on a daily basis. + + +.. class:: ScheduledEventWeekday + + Represents a scheduled event weekday. + + .. versionadded:: 2.7 + + .. attribute:: monday + + Monday, the first day of the week. Index of 0. + + .. attribute:: tuesday + + Tuesday, the second day of the week. Index of 1. + + .. attribute:: wednesday + + Wednesday, the third day of the week. Index of 2. + + .. attribute:: thursday + + Thrusday, the fourth day of the week. Index of 3. + + .. attribute:: friday + + Friday, the fifth day of the week. Index of 4. + + .. attribute:: saturday + + Saturday, the sixth day of the week. Index of 5. + + .. attribute:: sunday + + Sunday, the seventh day of the week. Index of 6.