diff --git a/discord/__init__.py b/discord/__init__.py index c7169908c3..a14d8d5780 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -63,6 +63,7 @@ from .commands import * from .cog import Cog from .welcome_screen import * +from .scheduled_events import ScheduledEvent, ScheduledEventLocation class VersionInfo(NamedTuple): diff --git a/discord/abc.py b/discord/abc.py index f6e876aca6..d25ad0a07a 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -43,6 +43,7 @@ runtime_checkable, ) +from .scheduled_events import ScheduledEvent from .iterators import HistoryIterator from .context_managers import Typing from .enums import ChannelType @@ -1035,6 +1036,7 @@ async def create_invite( max_uses: int = 0, temporary: bool = False, unique: bool = True, + target_event: Optional[ScheduledEvent] = None, target_type: Optional[InviteTarget] = None, target_user: Optional[User] = None, target_application_id: Optional[int] = None, @@ -1073,11 +1075,20 @@ async def create_invite( .. versionadded:: 2.0 - target_application_id:: Optional[:class:`int`] + target_application_id: Optional[:class:`int`] The id of the embedded application for the invite, required if `target_type` is `TargetType.embedded_application`. .. versionadded:: 2.0 + target_event: Optional[:class:`ScheduledEvent`] + The scheduled event object to link to the event. + Shortcut to :meth:`Invite.set_scheduled_event` + + See :meth:`Invite.set_scheduled_event` for more + info on event invite linking. + + .. versionadded:: 2.0 + Raises ------- ~discord.HTTPException @@ -1103,7 +1114,10 @@ async def create_invite( target_user_id=target_user.id if target_user else None, target_application_id=target_application_id, ) - return Invite.from_incomplete(data=data, state=self._state) + invite = Invite.from_incomplete(data=data, state=self._state) + if target_event: + invite.set_scheduled_event(target_event) + return invite async def invites(self) -> List[Invite]: """|coro| diff --git a/discord/audit_logs.py b/discord/audit_logs.py index 7e09d2c21f..f98e09ecad 100644 --- a/discord/audit_logs.py +++ b/discord/audit_logs.py @@ -61,6 +61,8 @@ from .stage_instance import StageInstance from .sticker import GuildSticker from .threads import Thread + from .scheduled_events import ScheduledEvent + from .state import ConnectionState def _transform_permissions(entry: AuditLogEntry, data: str) -> Permissions: @@ -147,7 +149,7 @@ def _transform(entry: AuditLogEntry, data: int) -> T: return _transform -def _transform_type(entry: AuditLogEntry, data: Union[int]) -> Union[enums.ChannelType, enums.StickerType]: +def _transform_type(entry: AuditLogEntry, data: int) -> Union[enums.ChannelType, enums.StickerType]: if entry.action.name.startswith('sticker_'): return enums.try_enum(enums.StickerType, data) else: @@ -210,14 +212,16 @@ class AuditLogChanges: 'privacy_level': (None, _enum_transformer(enums.StagePrivacyLevel)), 'format_type': (None, _enum_transformer(enums.StickerFormatType)), 'type': (None, _transform_type), + 'status': (None, _enum_transformer(enums.ScheduledEventStatus)), + 'entity_type': ('location_type', _enum_transformer(enums.ScheduledEventLocationType)), } # fmt: on - def __init__(self, entry: AuditLogEntry, data: List[AuditLogChangePayload]): + def __init__(self, entry: AuditLogEntry, data: List[AuditLogChangePayload], *, state: ConnectionState): self.before = AuditLogDiff() self.after = AuditLogDiff() - for elem in data: + for elem in sorted(data, key=lambda i: i['key']): attr = elem['key'] # special cases for role add/remove @@ -246,6 +250,14 @@ def __init__(self, entry: AuditLogEntry, data: List[AuditLogChangePayload]): if transformer: before = transformer(entry, before) + if attr == 'location': + if hasattr(self.before, 'location_type'): + from .scheduled_events import ScheduledEventLocation + if self.before.location_type is enums.ScheduledEventLocationType.external: + before = ScheduledEventLocation(state=state, location=before) + elif hasattr(self.before, 'channel'): + before = ScheduledEventLocation(state=state, location=self.before.channel) + setattr(self.before, attr, before) try: @@ -256,6 +268,14 @@ def __init__(self, entry: AuditLogEntry, data: List[AuditLogChangePayload]): if transformer: after = transformer(entry, after) + if attr == 'location': + if hasattr(self.after, 'location_type'): + from .scheduled_events import ScheduledEventLocation + if self.after.location_type is enums.ScheduledEventLocationType.external: + after = ScheduledEventLocation(state=state, location=after) + elif hasattr(self.after, 'channel'): + after = ScheduledEventLocation(state=state, location=self.after.channel) + setattr(self.after, attr, after) # add an alias @@ -463,7 +483,7 @@ def category(self) -> enums.AuditLogActionCategory: @utils.cached_property def changes(self) -> AuditLogChanges: """:class:`AuditLogChanges`: The list of changes this entry has.""" - obj = AuditLogChanges(self, self._changes) + obj = AuditLogChanges(self, self._changes, state=self._state) del self._changes return obj @@ -523,3 +543,6 @@ def _convert_target_sticker(self, target_id: int) -> Union[GuildSticker, Object] def _convert_target_thread(self, target_id: int) -> Union[Thread, Object]: return self.guild.get_thread(target_id) or Object(id=target_id) + + def _convert_target_scheduled_event(self, target_id: int) -> Union[ScheduledEvent, None]: + return self.guild.get_scheduled_event(target_id) or Object(id=target_id) diff --git a/discord/client.py b/discord/client.py index 350904c140..cbd7db895e 100644 --- a/discord/client.py +++ b/discord/client.py @@ -1337,7 +1337,7 @@ async def fetch_stage_instance(self, channel_id: int, /) -> StageInstance: # Invite management - async def fetch_invite(self, url: Union[Invite, str], *, with_counts: bool = True, with_expiration: bool = True) -> Invite: + async def fetch_invite(self, url: Union[Invite, str], *, with_counts: bool = True, with_expiration: bool = True, event_id: Snowflake = None) -> Invite: """|coro| Gets an :class:`.Invite` from a discord.gg URL or ID. @@ -1361,6 +1361,13 @@ async def fetch_invite(self, url: Union[Invite, str], *, with_counts: bool = Tru :attr:`.Invite.expires_at` field. .. versionadded:: 2.0 + event_id: :class:`int` + The ID of the scheduled event to be associated with the event. + + See :meth:`Invite.set_scheduled_event` for more + info on event invite linking. + + ..versionadded:: 2.0 Raises ------- @@ -1376,7 +1383,7 @@ async def fetch_invite(self, url: Union[Invite, str], *, with_counts: bool = Tru """ invite_id = utils.resolve_invite(url) - data = await self.http.get_invite(invite_id, with_counts=with_counts, with_expiration=with_expiration) + data = await self.http.get_invite(invite_id, with_counts=with_counts, with_expiration=with_expiration, guild_scheduled_event_id=event_id) return Invite.from_incomplete(state=self._connection, data=data) async def delete_invite(self, invite: Union[Invite, str]) -> None: diff --git a/discord/commands/errors.py b/discord/commands/errors.py index 794c34d82b..5a4b47eec8 100644 --- a/discord/commands/errors.py +++ b/discord/commands/errors.py @@ -1,64 +1,64 @@ -""" -The MIT License (MIT) - -Copyright (c) 2021-present Pycord Development - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. -""" - -from ..errors import DiscordException - -__all__ = ( - "ApplicationCommandError", - "CheckFailure", - "ApplicationCommandInvokeError", -) - -class ApplicationCommandError(DiscordException): - r"""The base exception type for all application command related errors. - - This inherits from :exc:`discord.DiscordException`. - - This exception and exceptions inherited from it are handled - in a special way as they are caught and passed into a special event - from :class:`.Bot`\, :func:`.on_command_error`. - """ - pass - -class CheckFailure(ApplicationCommandError): - """Exception raised when the predicates in :attr:`.Command.checks` have failed. - - This inherits from :exc:`ApplicationCommandError` - """ - pass - -class ApplicationCommandInvokeError(ApplicationCommandError): - """Exception raised when the command being invoked raised an exception. - - This inherits from :exc:`ApplicationCommandError` - - Attributes - ----------- - original: :exc:`Exception` - The original exception that was raised. You can also get this via - the ``__cause__`` attribute. - """ - def __init__(self, e: Exception) -> None: - self.original: Exception = e - super().__init__(f'Application Command raised an exception: {e.__class__.__name__}: {e}') +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from ..errors import DiscordException + +__all__ = ( + "ApplicationCommandError", + "CheckFailure", + "ApplicationCommandInvokeError", +) + +class ApplicationCommandError(DiscordException): + r"""The base exception type for all application command related errors. + + This inherits from :exc:`discord.DiscordException`. + + This exception and exceptions inherited from it are handled + in a special way as they are caught and passed into a special event + from :class:`.Bot`\, :func:`.on_command_error`. + """ + pass + +class CheckFailure(ApplicationCommandError): + """Exception raised when the predicates in :attr:`.Command.checks` have failed. + + This inherits from :exc:`ApplicationCommandError` + """ + pass + +class ApplicationCommandInvokeError(ApplicationCommandError): + """Exception raised when the command being invoked raised an exception. + + This inherits from :exc:`ApplicationCommandError` + + Attributes + ----------- + original: :exc:`Exception` + The original exception that was raised. You can also get this via + the ``__cause__`` attribute. + """ + def __init__(self, e: Exception) -> None: + self.original: Exception = e + super().__init__(f'Application Command raised an exception: {e.__class__.__name__}: {e}') diff --git a/discord/enums.py b/discord/enums.py index 71414d81a3..6988f0cf07 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -57,6 +57,9 @@ 'InteractionResponseType', 'NSFWLevel', 'EmbeddedActivity', + 'ScheduledEventStatus', + 'ScheduledEventPrivacyLevel', + 'ScheduledEventLocationType', ) @@ -360,9 +363,9 @@ class AuditLogAction(Enum): sticker_create = 90 sticker_update = 91 sticker_delete = 92 - scheduled_event_create = 100 - scheduled_event_update = 101 - scheduled_event_delete = 102 + scheduled_event_create = 100 + scheduled_event_update = 101 + scheduled_event_delete = 102 thread_create = 110 thread_update = 111 thread_delete = 112 @@ -372,53 +375,53 @@ class AuditLogAction(Enum): def category(self) -> Optional[AuditLogActionCategory]: # fmt: off lookup: Dict[AuditLogAction, Optional[AuditLogActionCategory]] = { - AuditLogAction.guild_update: AuditLogActionCategory.update, - AuditLogAction.channel_create: AuditLogActionCategory.create, - AuditLogAction.channel_update: AuditLogActionCategory.update, - AuditLogAction.channel_delete: AuditLogActionCategory.delete, - AuditLogAction.overwrite_create: AuditLogActionCategory.create, - AuditLogAction.overwrite_update: AuditLogActionCategory.update, - AuditLogAction.overwrite_delete: AuditLogActionCategory.delete, - AuditLogAction.kick: None, - AuditLogAction.member_prune: None, - AuditLogAction.ban: None, - AuditLogAction.unban: None, - AuditLogAction.member_update: AuditLogActionCategory.update, - AuditLogAction.member_role_update: AuditLogActionCategory.update, - AuditLogAction.member_move: None, - AuditLogAction.member_disconnect: None, - AuditLogAction.bot_add: None, - AuditLogAction.role_create: AuditLogActionCategory.create, - AuditLogAction.role_update: AuditLogActionCategory.update, - AuditLogAction.role_delete: AuditLogActionCategory.delete, - AuditLogAction.invite_create: AuditLogActionCategory.create, - AuditLogAction.invite_update: AuditLogActionCategory.update, - AuditLogAction.invite_delete: AuditLogActionCategory.delete, - AuditLogAction.webhook_create: AuditLogActionCategory.create, - AuditLogAction.webhook_update: AuditLogActionCategory.update, - AuditLogAction.webhook_delete: AuditLogActionCategory.delete, - AuditLogAction.emoji_create: AuditLogActionCategory.create, - AuditLogAction.emoji_update: AuditLogActionCategory.update, - AuditLogAction.emoji_delete: AuditLogActionCategory.delete, - AuditLogAction.message_delete: AuditLogActionCategory.delete, - AuditLogAction.message_bulk_delete: AuditLogActionCategory.delete, - AuditLogAction.message_pin: None, - AuditLogAction.message_unpin: None, - AuditLogAction.integration_create: AuditLogActionCategory.create, - AuditLogAction.integration_update: AuditLogActionCategory.update, - AuditLogAction.integration_delete: AuditLogActionCategory.delete, - AuditLogAction.stage_instance_create: AuditLogActionCategory.create, - AuditLogAction.stage_instance_update: AuditLogActionCategory.update, - AuditLogAction.stage_instance_delete: AuditLogActionCategory.delete, - AuditLogAction.sticker_create: AuditLogActionCategory.create, - AuditLogAction.sticker_update: AuditLogActionCategory.update, - AuditLogAction.sticker_delete: AuditLogActionCategory.delete, + AuditLogAction.guild_update: AuditLogActionCategory.update, + AuditLogAction.channel_create: AuditLogActionCategory.create, + AuditLogAction.channel_update: AuditLogActionCategory.update, + AuditLogAction.channel_delete: AuditLogActionCategory.delete, + AuditLogAction.overwrite_create: AuditLogActionCategory.create, + AuditLogAction.overwrite_update: AuditLogActionCategory.update, + AuditLogAction.overwrite_delete: AuditLogActionCategory.delete, + AuditLogAction.kick: None, + AuditLogAction.member_prune: None, + AuditLogAction.ban: None, + AuditLogAction.unban: None, + AuditLogAction.member_update: AuditLogActionCategory.update, + AuditLogAction.member_role_update: AuditLogActionCategory.update, + AuditLogAction.member_move: None, + AuditLogAction.member_disconnect: None, + AuditLogAction.bot_add: None, + AuditLogAction.role_create: AuditLogActionCategory.create, + AuditLogAction.role_update: AuditLogActionCategory.update, + AuditLogAction.role_delete: AuditLogActionCategory.delete, + AuditLogAction.invite_create: AuditLogActionCategory.create, + AuditLogAction.invite_update: AuditLogActionCategory.update, + AuditLogAction.invite_delete: AuditLogActionCategory.delete, + AuditLogAction.webhook_create: AuditLogActionCategory.create, + AuditLogAction.webhook_update: AuditLogActionCategory.update, + AuditLogAction.webhook_delete: AuditLogActionCategory.delete, + AuditLogAction.emoji_create: AuditLogActionCategory.create, + AuditLogAction.emoji_update: AuditLogActionCategory.update, + AuditLogAction.emoji_delete: AuditLogActionCategory.delete, + AuditLogAction.message_delete: AuditLogActionCategory.delete, + AuditLogAction.message_bulk_delete: AuditLogActionCategory.delete, + AuditLogAction.message_pin: None, + AuditLogAction.message_unpin: None, + AuditLogAction.integration_create: AuditLogActionCategory.create, + AuditLogAction.integration_update: AuditLogActionCategory.update, + AuditLogAction.integration_delete: AuditLogActionCategory.delete, + AuditLogAction.stage_instance_create: AuditLogActionCategory.create, + AuditLogAction.stage_instance_update: AuditLogActionCategory.update, + AuditLogAction.stage_instance_delete: AuditLogActionCategory.delete, + AuditLogAction.sticker_create: AuditLogActionCategory.create, + AuditLogAction.sticker_update: AuditLogActionCategory.update, + AuditLogAction.sticker_delete: AuditLogActionCategory.delete, AuditLogAction.scheduled_event_create: AuditLogActionCategory.create, AuditLogAction.scheduled_event_update: AuditLogActionCategory.update, AuditLogAction.scheduled_event_delete: AuditLogActionCategory.delete, - AuditLogAction.thread_create: AuditLogActionCategory.create, - AuditLogAction.thread_update: AuditLogActionCategory.update, - AuditLogAction.thread_delete: AuditLogActionCategory.delete, + AuditLogAction.thread_create: AuditLogActionCategory.create, + AuditLogAction.thread_update: AuditLogActionCategory.update, + AuditLogAction.thread_delete: AuditLogActionCategory.delete, } # fmt: on return lookup[self] @@ -699,6 +702,31 @@ class EmbeddedActivity(Enum): word_snacks_dev = 879864010126786570 youtube_together = 755600276941176913 + +class ScheduledEventStatus(Enum): + scheduled = 1 + active = 2 + completed = 3 + canceled = 4 + cancelled = 4 + + def __int__(self): + return self.value + + +class ScheduledEventPrivacyLevel(Enum): + guild_only = 2 + + def __int__(self): + return self.value + + +class ScheduledEventLocationType(Enum): + stage_instance = 1 + voice = 2 + external = 3 + + T = TypeVar('T') def create_unknown_value(cls: Type[T], val: Any) -> T: diff --git a/discord/errors.py b/discord/errors.py index 6cc549c61d..8d6fa9e852 100644 --- a/discord/errors.py +++ b/discord/errors.py @@ -59,7 +59,7 @@ 'ExtensionNotLoaded', 'NoEntryPointError', 'ExtensionFailed', - 'ExtensionNotFound' + 'ExtensionNotFound', ) diff --git a/discord/flags.py b/discord/flags.py index bdf646e34e..b8ab20fb2b 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -926,6 +926,27 @@ def dm_typing(self): This does not correspond to any attributes or classes in the library in terms of cache. """ return 1 << 14 + + @flag_value + def scheduled_events(self): + """:class:`bool`: Whether "scheduled event" related events are enabled. + + This corresponds to the following events: + + - :func:`on_scheduled_event_create` + - :func:`on_scheduled_event_update` + - :func:`on_scheduled_event_delete` + - :func:`on_scheduled_event_user_add` + - :func:`on_raw_scheduled_event_user_add` + - :func:`on_scheduled_event_user_remove` + - :func:`on_raw_scheduled_event_user_remove` + + This also corresponds to the following attributes and classes in terms of cache: + + - :class:`ScheduledEvent` + - :meth:`Guild.get_scheduled_event` + """ + return 1 << 16 @fill_with_flags() diff --git a/discord/guild.py b/discord/guild.py index 1a31fab748..249f55073c 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -52,8 +52,7 @@ from .colour import Colour from .errors import InvalidArgument, ClientException from .channel import * -from .channel import _guild_channel_factory -from .channel import _threaded_guild_channel_factory +from .channel import _guild_channel_factory, _threaded_guild_channel_factory from .enums import ( AuditLogAction, VideoQualityMode, @@ -64,6 +63,8 @@ ContentFilter, NotificationLevel, NSFWLevel, + ScheduledEventLocationType, + ScheduledEventPrivacyLevel, ) from .mixins import Hashable from .user import User @@ -78,7 +79,7 @@ from .sticker import GuildSticker from .file import File from .welcome_screen import WelcomeScreen, WelcomeScreenChannel - +from .scheduled_events import ScheduledEvent, ScheduledEventLocation __all__ = ( 'Guild', @@ -253,13 +254,13 @@ class Guild(Hashable): The guild's NSFW level. .. versionadded:: 2.0 - + approximate_member_count: Optional[:class:`int`] The approximate number of members in the guild. This is ``None`` unless the guild is obtained using :meth:`Client.fetch_guild` with ``with_counts=True``. .. versionadded:: 2.0 - + approximate_presence_count: Optional[:class:`int`] The approximate number of members currently active in the guild. This includes idle, dnd, online, and invisible members. Offline members are excluded. @@ -293,6 +294,7 @@ class Guild(Hashable): 'premium_progress_bar_enabled', 'preferred_locale', 'nsfw_level', + '_scheduled_events', '_members', '_channels', '_icon', @@ -310,8 +312,8 @@ class Guild(Hashable): '_public_updates_channel_id', '_stage_instances', '_threads', - "approximate_member_count", - "approximate_presence_count", + 'approximate_member_count', + 'approximate_presence_count', ) _PREMIUM_GUILD_LIMITS: ClassVar[Dict[Optional[int], _GuildLimit]] = { @@ -323,8 +325,14 @@ class Guild(Hashable): } def __init__(self, *, data: GuildPayload, state: ConnectionState): + # NOTE: + # Adding an attribute here and getting an AttributeError saying + # the attr doesn't exist? it has something to do with the order + # of the attr in __slots__ + self._channels: Dict[int, GuildChannel] = {} self._members: Dict[int, Member] = {} + self._scheduled_events: Dict[int, ScheduledEvent] = {} self._voice_states: Dict[int, VoiceState] = {} self._threads: Dict[int, Thread] = {} self._state: ConnectionState = state @@ -350,6 +358,17 @@ def _store_thread(self, payload: ThreadPayload, /) -> Thread: def _remove_member(self, member: Snowflake, /) -> None: self._members.pop(member.id, None) + def _add_scheduled_event(self, event: ScheduledEvent, /) -> None: + self._scheduled_events[event.id] = event + + def _remove_scheduled_event(self, event: Snowflake, /) -> None: + self._scheduled_events.pop(event.id, None) + + def _scheduled_events_from_list(self, events: List[ScheduledEvent], /) -> None: + self._scheduled_events.clear() + for event in events: + self._scheduled_events[event.id] = event + def _add_thread(self, thread: Thread, /) -> None: self._threads[thread.id] = thread @@ -495,6 +514,12 @@ def _from_data(self, guild: GuildPayload) -> None: if cache_joined or member.id == self_id: self._add_member(member) + events = [] + for event in guild.get('guild_scheduled_events', []): + creator = None if not event.get('creator', None) else self.get_member(event.get('creator_id')) + events.append(ScheduledEvent(state=self._state, guild=self, creator=creator, data=event)) + self._scheduled_events_from_list(events) + self._sync(guild) self._large: Optional[bool] = None if member_count is None else self._member_count >= 250 @@ -3046,7 +3071,7 @@ async def edit_welcome_screen(self, **options): The guild must have ``COMMUNITY`` in :attr:`Guild.features` Parameters - ------------ + ----------- description: Optional[:class:`str`] The new description of welcome screen. @@ -3088,4 +3113,180 @@ async def edit_welcome_screen(self, **options): if options: new = await self._state.http.edit_welcome_screen(self.id, options, reason=options.get('reason')) return WelcomeScreen(data=new, guild=self) + + async def fetch_scheduled_events(self, *, with_user_count: bool = True) -> List[ScheduledEvent]: + """|coro| + Returns a list of :class:`ScheduledEvent` in the guild. + + .. note:: + + This method is an API call. For general usage, consider :attr:`scheduled_events` instead. + + Parameters + ----------- + with_user_count: Optional[:class:`bool`] + If the scheduled event should be fetch with the number of + users that are interested in the event. + Defaults to ``True`` + + Raises + ------- + ClientException + The scheduled events intent is not enabled. + HTTPException + Getting the scheduled events failed. + + Returns + -------- + List[:class:`ScheduledEvent`] + The fetched scheduled events + """ + data = await self._state.http.get_scheduled_events(self.id, with_user_count=with_user_count) + result = [] + for event in data: + creator = None if not event.get('creator', None) else self.get_member(event.get('creator_id')) + result.append(ScheduledEvent(state=self._state, guild=self, creator=creator, data=event)) + + self._scheduled_events_from_list(result) + return result + + async def fetch_scheduled_event( + self, + event_id: int, + /, + *, + with_user_count: bool = True + ) -> Optional[ScheduledEvent]: + """|coro| + + Retrieves a :class:`ScheduledEvent` from event ID. + + .. note:: + + This method is an API call. If you have :attr:`Intents.scheduled_events`, consider :meth:`get_scheduled_event` instead. + + Parameters + ----------- + event_id: :class:`int` + The event's ID to fetch with. + + Raises + ------- + HTTPException + Fetching the event failed. + NotFound + Event not found. + + Returns + -------- + Optional[:class:`ScheduledEvent`] + The scheduled event from the event ID. + """ + data = await self._state.http.get_scheduled_event(guild_id=self.id, event_id=event_id, with_user_count=with_user_count) + creator = None if not data.get('creator', None) else self.get_member(data.get('creator_id')) + event = ScheduledEvent(state=self._state, guild=self, creator=creator, data=data) + + old_event = self._scheduled_events.get(event.id) + if old_event: + self._scheduled_events[event.id] = event + else: + self._add_scheduled_event(event) + + return event + + def get_scheduled_event(self, event_id: int, /) -> Optional[ScheduledEvent]: + """Returns a Scheduled Event with the given ID. + + Parameters + ----------- + event_id: :class:`int` + The ID to search for. + + Returns + -------- + Optional[:class:`ScheduledEvent`] + The scheduled event or ``None`` if not found. + """ + return self._scheduled_events.get(event_id) + + async def create_scheduled_event( + self, + *, + name: str, + description: str = MISSING, + start_time: datetime, + end_time: datetime = MISSING, + location: Union[str, int, VoiceChannel, StageChannel, ScheduledEventLocation], + privacy_level: ScheduledEventPrivacyLevel = ScheduledEventPrivacyLevel.guild_only, + reason: Optional[str] = None + ) -> Optional[ScheduledEvent]: + """|coro| + Creates a scheduled event. + + 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` + 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. + 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. + reason: Optional[:class:`str`] + The reason to show in the audit log. + + Raises + ------- + Forbidden + You do not have the Manage Events permission. + HTTPException + The operation failed. + + Returns + -------- + Optional[:class:`ScheduledEvent`] + The created scheduled event. + """ + payload: Dict[str, Union[str, int]] = {} + + payload["name"] = name + + payload["scheduled_start_time"] = start_time.isoformat() + + payload["privacy_level"] = int(privacy_level) + + if not isinstance(location, ScheduledEventLocation): + location = ScheduledEventLocation(state=self._state, location=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 description is not MISSING: + payload["description"] = description + + if end_time is not MISSING: + payload["scheduled_end_time"] = end_time.isoformat() + + data = await self._state.http.create_scheduled_event(guild_id=self.id, reason=reason, **payload) + event = ScheduledEvent(state=self._state, guild=self, creator=self.me, data=data) + self._add_scheduled_event(event) + return event + + @property + def scheduled_events(self) -> List[ScheduledEvent]: + """List[:class:`.ScheduledEvent`]: A list of scheduled events in this guild.""" + return list(self._scheduled_events.values()) diff --git a/discord/http.py b/discord/http.py index 9a537bfcbd..a6a9cc665f 100644 --- a/discord/http.py +++ b/discord/http.py @@ -86,6 +86,7 @@ voice, sticker, welcome_screen, + scheduled_events, ) from .types.snowflake import Snowflake, SnowflakeList from .types.message import Attachment @@ -1441,12 +1442,14 @@ def create_invite( return self.request(r, reason=reason, json=payload) def get_invite( - self, invite_id: str, *, with_counts: bool = True, with_expiration: bool = True + self, invite_id: str, *, with_counts: bool = True, with_expiration: bool = True, guild_scheduled_event_id: Snowflake = None ) -> Response[invite.Invite]: params = { 'with_counts': int(with_counts), 'with_expiration': int(with_expiration), + 'guild_scheduled_event_id': int(guild_scheduled_event_id), } + return self.request(Route('GET', '/invites/{invite_id}', invite_id=invite_id), params=params) def invites_from(self, guild_id: Snowflake) -> Response[List[invite.Invite]]: @@ -1599,6 +1602,74 @@ def edit_stage_instance(self, channel_id: Snowflake, *, reason: Optional[str] = def delete_stage_instance(self, channel_id: Snowflake, *, reason: Optional[str] = None) -> Response[None]: return self.request(Route('DELETE', '/stage-instances/{channel_id}', channel_id=channel_id), reason=reason) + + # Guild scheduled events management + + def get_scheduled_events(self, guild_id: Snowflake, with_user_count: bool = True) -> Response[List[scheduled_events.ScheduledEvent]]: + params = { + 'with_user_count': int(with_user_count), + } + + return self.request(Route('GET', '/guilds/{guild_id}/scheduled-events', guild_id=guild_id), params=params) + + def get_scheduled_event(self, guild_id: Snowflake, event_id: Snowflake, with_user_count: bool = True) -> Response[scheduled_events.ScheduledEvent]: + params = { + 'with_user_count': int(with_user_count), + } + + return self.request(Route('GET', '/guilds/{guild_id}/scheduled-events/{event_id}', guild_id=guild_id, event_id=event_id), params=params) + + def create_scheduled_event(self, guild_id: Snowflake, reason: Optional[str] = None, **payload: Any) -> Response[scheduled_events.ScheduledEvent]: + valid_keys = ( + 'channel_id', + 'name', + 'privacy_level', + 'scheduled_start_time', + 'scheduled_end_time', + 'description', + 'entity_type', + 'entity_metadata', + ) + payload = {k: v for k, v in payload.items() if k in valid_keys} + + return self.request(Route('POST', '/guilds/{guild_id}/scheduled-events', guild_id=guild_id), json=payload, reason=reason) + + def delete_scheduled_event(self, guild_id: Snowflake, event_id: Snowflake) -> Response[None]: + return self.request(Route('DELETE', '/guilds/{guild_id}/scheduled-events/{event_id}', guild_id=guild_id, event_id=event_id)) + + def edit_scheduled_event(self, guild_id: Snowflake, event_id: Snowflake, reason: Optional[str] = None, **payload: Any) -> Response[scheduled_events.ScheduledEvent]: + valid_keys = ( + 'channel_id', + 'name', + 'privacy_level', + 'scheduled_start_time', + 'scheduled_end_time', + 'description', + 'entity_type', + 'status', + 'entity_metadata', + ) + payload = {k: v for k, v in payload.items() if k in valid_keys} + + return self.request(Route('PATCH', '/guilds/{guild_id}/scheduled-events/{event_id}', guild_id=guild_id, event_id=event_id), json=payload, reason=reason) + + def get_scheduled_event_users( + self, + guild_id: Snowflake, + event_id: Snowflake, + limit: int, + with_member: bool = False, + before: Snowflake = None, + after: Snowflake = None + ) -> Response[List[scheduled_events.ScheduledEventSubscriber]]: + params = { + 'limit': int(limit), + 'with_member': int(with_member), + 'before': int(before), + 'after': int(after), + } + + return self.request(Route('GET', '/guilds/{guild_id}/scheduled-events/{event_id}/users', guild_id=guild_id, event_id=event_id), params=params) # Application commands (global) diff --git a/discord/invite.py b/discord/invite.py index cb1d1e6cdf..bf17ba239e 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -52,6 +52,8 @@ from .guild import Guild from .abc import GuildChannel from .user import User + from .scheduled_events import ScheduledEvent + from .types.scheduled_events import ScheduledEvent as ScheduledEventPayload InviteGuildType = Union[Guild, 'PartialInviteGuild', Object] InviteChannelType = Union[GuildChannel, 'PartialInviteChannel', Object] @@ -305,6 +307,8 @@ class Invite(Hashable): The embedded application the invite targets, if any. .. versionadded:: 2.0 + scheduled_event: Optional[:class:`ScheduledEvent`] + The scheduled event linked with the invite. """ __slots__ = ( @@ -323,6 +327,7 @@ class Invite(Hashable): '_state', 'approximate_member_count', 'approximate_presence_count', + 'scheduled_event', 'target_application', 'expires_at', ) @@ -362,6 +367,10 @@ def __init__( self.target_type: InviteTarget = try_enum(InviteTarget, data.get("target_type", 0)) + from .scheduled_events import ScheduledEvent + scheduled_event: ScheduledEventPayload = data.get('guild_scheduled_event') + self.scheduled_event: Optional[ScheduledEvent] = ScheduledEvent(state=state, data=scheduled_event) if scheduled_event else None + application = data.get('target_application') self.target_application: Optional[PartialAppInfo] = ( PartialAppInfo(data=application, state=state) if application else None @@ -438,7 +447,8 @@ def __repr__(self) -> str: return ( f'' + f'members={self.approximate_member_count} ' + f'scheduled_event={self.scheduled_event}>' ) def __hash__(self) -> int: @@ -452,7 +462,7 @@ def id(self) -> str: @property def url(self) -> str: """:class:`str`: A property that retrieves the invite URL.""" - return self.BASE + '/' + self.code + return self.BASE + '/' + self.code + (f'?event={self.scheduled_event.id}' if self.scheduled_event else '') async def delete(self, *, reason: Optional[str] = None): """|coro| @@ -477,3 +487,22 @@ async def delete(self, *, reason: Optional[str] = None): """ await self._state.http.delete_invite(self.code, reason=reason) + + def set_scheduled_event(self, event: ScheduledEvent) -> None: + """Links the given scheduled event to this invite. + + .. note:: + + Scheduled events aren't actually associated to invites on the API. + Any guild channel invite can have an event attached to it. Using + :meth:`abc.GuildChannel.create_invite`, :meth:`Client.fetch_invite`, + or this method, you can link scheduled events. + + .. versionadded:: 2.0 + + Parameters + ----------- + event: :class:`ScheduledEvent` + The scheduled event object to link. + """ + self.scheduled_event = event diff --git a/discord/iterators.py b/discord/iterators.py index d2dc2c9791..5ec35ad93f 100644 --- a/discord/iterators.py +++ b/discord/iterators.py @@ -40,6 +40,7 @@ 'AuditLogIterator', 'GuildIterator', 'MemberIterator', + 'ScheduledEventSubscribersIterator', ) if TYPE_CHECKING: @@ -269,7 +270,6 @@ class HistoryIterator(_AsyncIterator['Message']): """ def __init__(self, messageable, limit, before=None, after=None, around=None, oldest_first=None): - if isinstance(before, datetime.datetime): before = Object(id=time_snowflake(before, high=False)) if isinstance(after, datetime.datetime): @@ -525,7 +525,6 @@ class GuildIterator(_AsyncIterator['Guild']): """ def __init__(self, bot, limit, before=None, after=None): - if isinstance(before, datetime.datetime): before = Object(id=time_snowflake(before, high=False)) if isinstance(after, datetime.datetime): @@ -752,3 +751,63 @@ async def fill_queue(self) -> None: def create_thread(self, data: ThreadPayload) -> Thread: from .threads import Thread return Thread(guild=self.guild, state=self.guild._state, data=data) + + +class ScheduledEventSubscribersIterator(_AsyncIterator[Union["User", "Member"]]): + def __init__(self, event, limit, with_member=False, before=None, after=None): + if isinstance(before, datetime.datetime): + before = Object(id=time_snowflake(before, high=False)) + if isinstance(after, datetime.datetime): + after = Object(id=time_snowflake(after, high=True)) + + self.event = event + self.limit = limit + self.with_member = with_member + self.before = before + self.after = after + + self.subscribers = asyncio.Queue() + self.get_subscribers = self.event._state.http.get_scheduled_event_users + + async def next(self) -> Union[User, Member]: + if self.subscribers.empty(): + await self.fill_subs() + + try: + return self.subscribers.get_nowait() + except asyncio.QueueEmpty: + raise NoMoreItems() + + def _get_retrieve(self): + l = self.limit + if l is None or l > 100: + r = 100 + else: + r = l + self.retrieve = r + return r > 0 + + def member_from_payload(self, data): + from .member import Member + + member = data.pop('member', None) + member['user'] = data + + return Member(data=member, guild=self.event.guild, state=self.event._state) + + def user_from_payload(self, data): + from .user import User + + return User(state=self.event._state, data=data) + + async def fill_subs(self): + if self._get_retrieve(): + 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, limit=self.retrieve, with_member=self.with_member, before=before, after=after) + + for element in reversed(data): + if 'member' in element: + self.subscribers.put(self.member_from_payload(element)) + else: + self.subscribers.put(self.user_from_payload(element)) diff --git a/discord/raw_models.py b/discord/raw_models.py index f5d5fc5612..48850a1681 100644 --- a/discord/raw_models.py +++ b/discord/raw_models.py @@ -40,12 +40,14 @@ ReactionClearEmojiEvent, IntegrationDeleteEvent, ThreadDeleteEvent, - TypingEvent + TypingEvent, + ScheduledEventSubscription, ) from .message import Message from .partial_emoji import PartialEmoji from .member import Member from .threads import Thread + from .guild import Guild @@ -59,6 +61,7 @@ 'RawIntegrationDeleteEvent', 'RawThreadDeleteEvent', 'RawTypingEvent', + 'RawScheduledEventSubscription', ) @@ -346,4 +349,30 @@ def __init__(self, data: TypingEvent) -> None: self.guild_id: Optional[int] = int(data['guild_id']) except KeyError: self.guild_id: Optional[int] = None - \ No newline at end of file + +class RawScheduledEventSubscription(_RawReprMixin): + """Represents the payload for a :func:`raw_scheduled_event_user_add` or + :func:`raw_scheduled_event_user_remove` event. + + .. versionadded:: 2.0 + + Attributes + ----------- + event_id: :class:`int` + The event ID where the typing originated from. + user_id: :class:`int` + The ID of the user that subscribed/unsubscribed. + guild: Optional[:class:`Guild`] + The guild where the subscription/unsubscription happened. + entity_type: :class:`str` + Can be either ``USER_ADD`` or ``USER_REMOVE`` depending on + the event called. + """ + + __slots__ = ("event_id", "guild", "user_id", "event_type") + + def __init__(self, data: ScheduledEventSubscription, event_type: str): + self.event_id: int = int(data['guild_scheduled_event_id']) + self.user_id: int = int(data['user_id']) + self.guild: Guild = None + self.event_type: str = event_type diff --git a/discord/scheduled_events.py b/discord/scheduled_events.py new file mode 100644 index 0000000000..240308b17c --- /dev/null +++ b/discord/scheduled_events.py @@ -0,0 +1,487 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +import datetime + +from . import utils +from typing import TYPE_CHECKING, Optional, Dict, Any, Union +from .enums import ( + ScheduledEventPrivacyLevel, + ScheduledEventStatus, + ScheduledEventLocationType, + try_enum, +) +from .mixins import Hashable +from .iterators import ScheduledEventSubscribersIterator +from .errors import ValidationError + +__all__ = ( + 'ScheduledEvent', + 'ScheduledEventLocation', +) + +if TYPE_CHECKING: + from .abc import Snowflake + from .state import ConnectionState + from .member import Member + from .guild import Guild + from .iterators import AsyncIterator + from .types.scheduled_events import ScheduledEvent as ScheduledEventPayload + from .types.channel import StageChannel, VoiceChannel + +MISSING = utils.MISSING + +class ScheduledEventLocation: + """Represents a scheduled event's location. + + Setting the ``location`` to its corresponding type will set the location type automatically: + - :class:`StageChannel`: :attr:`ScheduledEventLocationType.external` + - :class:`VoiceChannel`: :attr:`ScheduledEventLocationType.voice` + - :class:`str`: :attr:`ScheduledEventLocationType.external` + + .. versionadded:: 2.0 + + Attributes + ---------- + value: Union[:class:`str`, :class:`int`, :class:`StageChannel`, :class:`VoiceChannel`] + The actual location of the scheduled event. + type: :class:`ScheduledEventLocationType` + The type of location. + """ + + __slots__ = ( + '_state', + 'value', + ) + + def __init__(self, *, state: ConnectionState, location: Union[str, int, StageChannel, VoiceChannel]): + self._state = state + if isinstance(location, int): + self.value = self._state._get_channel(int(location)) + else: + self.value = location + + def __repr__(self) -> str: + return f"" + + def __str__(self) -> str: + return self.value + + @property + def type(self) -> ScheduledEventLocationType: + if isinstance(self.value, str): + return ScheduledEventLocationType.external + elif self.value.__class__.__name__ == "StageChannel": + return ScheduledEventLocationType.stage_instance + elif self.value.__class__.__name__ == "VoiceChannel": + return ScheduledEventLocationType.voice + + +class ScheduledEvent(Hashable): + """Represents a Discord Guild Scheduled Event. + + .. container:: operations + + .. describe:: x == y + + Checks if two scheduled events are equal. + + .. describe:: x != y + + Checks if two scheduled events are not equal. + + .. describe:: hash(x) + + Returns the scheduled event's hash. + + .. describe:: str(x) + + Returns the scheduled event's name. + + .. versionadded:: 2.0 + + Attributes + ---------- + name: :class:`str` + The name of the scheduled event. + description: Optional[:class:`str`] + The description of the scheduled event. + start_time: :class:`datetime.datetime` + The time when the event will start + 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`] + The number of users that have marked themselves as interested for the event. + interested: Optional[:class:`int`] + Alias to :attr:`.subscriber_count` + creator_id: Optional[:class:`int`] + The ID of the user who created the event. + It may be ``None`` because events created before October 25th, 2021, haven't + had their creators tracked. + creator: Optional[:class:`User`] + The resolved user object of who created the event. + 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 use this attribute. + created_at: :class:`datetime.datetime` + The datetime object of when the event was created. + guild: :class:`Guild` + The guild where the scheduled event is happening. + """ + + __slots__ = ( + 'id', + 'name', + 'description', + 'start_time', + 'end_time', + 'status', + 'creator_id', + 'creator', + 'location', + 'guild', + '_state', + 'subscriber_count', + ) + + def __init__(self, *, state: ConnectionState, guild: Guild, creator: Optional[Member], data: ScheduledEventPayload): + self._state: ConnectionState = state + + self.id: int = int(data.get('id')) + self.guild: Guild = guild + self.name: str = data.get('name') + self.description: Optional[str] = data.get('description', None) + #self.image: Optional[str] = data.get('image', None) + self.start_time: datetime.datetime = datetime.datetime.fromisoformat(data.get('scheduled_start_time')) + end_time = data.get('scheduled_end_time', None) + if end_time != None: + end_time = datetime.datetime.fromisoformat(end_time) + self.end_time: Optional[datetime.datetime] = end_time + self.status: ScheduledEventStatus = try_enum(ScheduledEventStatus, data.get('status')) + self.subscriber_count: Optional[int] = data.get('user_count', None) + self.creator_id = data.get('creator_id', None) + self.creator: Optional[Member] = creator + + entity_metadata = data.get('entity_metadata') + channel_id = data.get('channel_id', None) + if channel_id != None: + self.location = ScheduledEventLocation(state=state, location=channel_id) + else: + self.location = ScheduledEventLocation(state=state, location=entity_metadata["location"]) + + def __str__(self) -> str: + return self.name + + def __repr__(self) -> str: + return ( + f'' + ) + + @property + def created_at(self) -> datetime.datetime: + """:class:`datetime.datetime`: Returns the scheduled event's creation time in UTC.""" + return utils.snowflake_time(self.id) + + @property + def interested(self) -> Optional[int]: + """An alias to :attr:`.subscriber_count`""" + return self.subscriber_count + + async def edit( + self, + *, + name: str = MISSING, + description: str = MISSING, + status: Union[int, ScheduledEventStatus] = MISSING, + location: Union[str, int, VoiceChannel, StageChannel, ScheduledEventLocation] = MISSING, + start_time: datetime.datetime = MISSING, + end_time: datetime.datetime = MISSING, + privacy_level: ScheduledEventPrivacyLevel = ScheduledEventPrivacyLevel.guild_only, + reason: Optional[str] = None + ) -> Optional[ScheduledEvent]: + """|coro| + + Edits the Scheduled Event's data + + All parameters are optional unless ``location.type`` is + :attr:`ScheduledEventLocationType.external`, then ``end_time`` + is required. + + Will return a new :class:`.ScheduledEvent` object if applicable. + + Parameters + ----------- + name: :class:`str` + The new name of the event. + description: :class:`str` + The new description of the event. + location: :class:`.ScheduledEventLocation` + The location of the event. + 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. + 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. + reason: Optional[:class:`str`] + The reason to show in the audit log. + + Raises + ------- + Forbidden + You do not have the Manage Events permission. + HTTPException + The operation failed. + + Returns + -------- + Optional[:class:`.ScheduledEvent`] + The newly updated scheduled event object. This is only returned when certain + fields are updated. + """ + payload: Dict[str, Any] = {} + + if name is not MISSING: + payload["name"] = name + + if description is not MISSING: + payload["description"] = description + + if status is not MISSING: + payload["status"] = int(status) + + if privacy_level is not MISSING: + payload["privacy_level"] = int(privacy_level) + + if not isinstance(location, ScheduledEventLocation): + location = ScheduledEventLocation(state=self._state, location=location) + + if location is not MISSING: + if location.type in (ScheduledEventLocationType.voice, ScheduledEventLocationType.stage_instance): + payload["channel_id"] = location.value.id + payload["entity_metadata"] = None + else: + payload["channel_id"] = None + payload["entity_metadata"] = {"location":str(location.value)} + + location = location if location is not MISSING else self.location + if end_time is MISSING and location.type is ScheduledEventLocationType.external: + raise ValidationError("end_time needs to be passed if location type is external.") + + if start_time is not MISSING: + payload["scheduled_start_time"] = start_time.isoformat() + + if end_time is not MISSING: + payload["scheduled_end_time"] = end_time.isoformat() + + 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) + + async def delete(self) -> None: + """|coro| + + Deletes the scheduled event. + + Raises + ------- + Forbidden + You do not have the Manage Events permission. + HTTPException + The operation failed. + """ + await self._state.http.delete_scheduled_event(self.guild.id, self.id) + + async def start(self, *, reason: Optional[str] = None) -> None: + """|coro| + + Starts the scheduled event. Shortcut from :meth:`.edit`. + + .. note:: + + This method can only be used if :attr:`.status` is :attr:`ScheduledEventStatus.scheduled`. + + Parameters + ----------- + reason: Optional[:class:`str`] + The reason to show in the audit log. + + Raises + ------- + Forbidden + You do not have the Manage Events permission. + HTTPException + The operation failed. + + Returns + -------- + Optional[:class:`.ScheduledEvent`] + The newly updated scheduled event object. + """ + return await self.edit(status=ScheduledEventStatus.active, reason=reason) + + async def complete(self, *, reason: Optional[str] = None) -> None: + """|coro| + + Ends/completes the scheduled event. Shortcut from :meth:`.edit`. + + .. note:: + + This method can only be used if :attr:`.status` is :attr:`ScheduledEventStatus.active`. + + Parameters + ----------- + reason: Optional[:class:`str`] + The reason to show in the audit log. + + Raises + ------- + Forbidden + You do not have the Manage Events permission. + HTTPException + The operation failed. + + Returns + -------- + Optional[:class:`.ScheduledEvent`] + The newly updated scheduled event object. + """ + return await self.edit(status=ScheduledEventStatus.completed, reason=reason) + + async def cancel(self, *, reason: Optional[str] = None) -> None: + """|coro| + + Cancels the scheduled event. Shortcut from :meth:`.edit`. + + .. note:: + + This method can only be used if :attr:`.status` is :attr:`ScheduledEventStatus.scheduled`. + + Parameters + ----------- + reason: Optional[:class:`str`] + The reason to show in the audit log. + + Raises + ------- + Forbidden + You do not have the Manage Events permission. + HTTPException + The operation failed. + + Returns + -------- + Optional[:class:`.ScheduledEvent`] + The newly updated scheduled event object. + """ + return await self.edit(status=ScheduledEventStatus.canceled, reason=reason) + + def subscribers( + self, + *, + limit: Optional[int] = None, + as_member: bool = False, + before: Optional[Union[Snowflake, datetime.datetime]] = None, + after: Optional[Union[Snowflake, datetime.datetime]] = None, + ) -> AsyncIterator: + """Returns an :class:`AsyncIterator` representing the users or members subscribed to the event. + + The ``after`` and ``before`` parameters must represent member + or user objects and meet the :class:`abc.Snowflake` abc. + + .. note:: + + Even is ``as_member`` is set to ``True``, if the user + is outside the guild, it will be a :class:`User` object. + + Examples + --------- + + Usage :: + + async for user in event.subscribers(limit=100): + print(user.name) + + Flattening into a list: :: + + users = await event.subscribers(limit=100).flatten() + # users is now a list of User... + + Getting members instead of user objects: :: + + async for member in event.subscribers(limit=100, as_member=True): + print(member.display_name) + + Parameters + ----------- + limit: Optional[:class:`int`] + The maximum number of results to return. + as_member: Optional[:class:`bool`] + Whether to fetch :class:`Member` objects instead of user objects. + There may still be :class:`User` objects if the user is outside + the guild. + before: Optional[Union[:class:`abc.Snowflake`, :class:`datetime.datetime`]] + Retrieves users before 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. + after: Optional[Union[:class:`abc.Snowflake`, :class:`datetime.datetime`]] + 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. + + Raises + ------- + HTTPException + Fetching the subscribed users failed. + + Yields + ------- + Union[:class:`User`, :class:`Member`] + The subscribed :class:`Member`. If ``as_member`` is set to + ``False`` or the user is outside the guild, it will be a + :class:`User` object. + """ + return ScheduledEventSubscribersIterator(event=self, limit=limit, with_member=as_member, before=before, after=after) diff --git a/discord/state.py b/discord/state.py index b2f25f302d..4c5b1b4ee4 100644 --- a/discord/state.py +++ b/discord/state.py @@ -48,7 +48,7 @@ from .raw_models import * from .member import Member from .role import Role -from .enums import ChannelType, try_enum, Status +from .enums import ChannelType, try_enum, Status, ScheduledEventStatus from . import utils from .flags import ApplicationFlags, Intents, MemberCacheFlags from .object import Object @@ -59,6 +59,7 @@ from .stage_instance import StageInstance from .threads import Thread, ThreadMember from .sticker import GuildSticker +from .scheduled_events import ScheduledEvent if TYPE_CHECKING: from .abc import PrivateChannel @@ -1216,6 +1217,74 @@ def parse_guild_members_chunk(self, data) -> None: complete = data.get('chunk_index', 0) + 1 == data.get('chunk_count') self.process_chunk_requests(guild_id, data.get('nonce'), members, complete) + def parse_guild_scheduled_event_create(self, data) -> None: + guild = self._get_guild(data['guild_id']) + if guild is None: + _log.debug('GUILD_SCHEDULED_EVENT_CREATE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + return + + scheduled_event = ScheduledEvent(state=self, guild=guild, data=data) + guild._add_scheduled_event(scheduled_event) + self.dispatch('scheduled_event_create', scheduled_event) + + def parse_guild_scheduled_event_update(self, data) -> None: + guild = self._get_guild(data['guild_id']) + if guild is None: + _log.debug('GUILD_SCHEDULED_EVENT_UPDATE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + return + + scheduled_event = ScheduledEvent(state=self, guild=guild, data=data) + old_event = guild.get_scheduled_event(data['id']) + guild._add_scheduled_event(scheduled_event) + self.dispatch('scheduled_event_update', old_event, scheduled_event) + + def parse_guild_scheduled_event_delete(self, data) -> None: + guild = self._get_guild(data['guild_id']) + if guild is None: + _log.debug('GUILD_SCHEDULED_EVENT_DELETE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + return + + scheduled_event = ScheduledEvent(state=self, guild=guild, data=data) + scheduled_event.status = ScheduledEventStatus.canceled + guild._remove_scheduled_event(scheduled_event) + self.dispatch('scheduled_event_delete', scheduled_event) + + def parse_guild_scheduled_event_user_add(self, data) -> None: + guild = self._get_guild(data['guild_id']) + if guild is None: + _log.debug('GUILD_SCHEDULED_EVENT_USER_ADD referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + return + + payload = RawScheduledEventSubscription(data, 'USER_ADD') + 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) + self.dispatch('scheduled_event_user_add', event, member) + + def parse_guild_scheduled_event_user_remove(self, data) -> None: + guild = self._get_guild(data['guild_id']) + if guild is None: + _log.debug('GUILD_SCHEDULED_EVENT_USER_REMOVE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + return + + payload = RawScheduledEventSubscription(data, 'USER_REMOVE') + 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) + self.dispatch('scheduled_event_user_remove', event, member) + def parse_guild_integrations_update(self, data) -> None: guild = self._get_guild(int(data['guild_id'])) if guild is not None: diff --git a/discord/types/audit_log.py b/discord/types/audit_log.py index 68f7a874a8..3ea643abdf 100644 --- a/discord/types/audit_log.py +++ b/discord/types/audit_log.py @@ -34,6 +34,7 @@ from .role import Role from .channel import ChannelType, VideoQualityMode, PermissionOverwrite from .threads import Thread +from .scheduled_events import ScheduledEvent AuditLogEvent = Literal[ 1, @@ -77,6 +78,9 @@ 90, 91, 92, + 100, + 101, + 102, 110, 111, 112, @@ -256,3 +260,4 @@ class AuditLog(TypedDict): audit_log_entries: List[AuditLogEntry] integrations: List[PartialIntegration] threads: List[Thread] + scheduled_events: List[ScheduledEvent] diff --git a/discord/types/guild.py b/discord/types/guild.py index e0a5db9b7f..782744b980 100644 --- a/discord/types/guild.py +++ b/discord/types/guild.py @@ -24,6 +24,8 @@ """ from typing import List, Literal, Optional, TypedDict + +from .scheduled_events import ScheduledEvent from .snowflake import Snowflake from .channel import GuildChannel from .voice import GuildVoiceState @@ -68,6 +70,7 @@ class _GuildOptional(TypedDict, total=False): premium_subscription_count: int premium_progress_bar_enabled: bool max_video_channel_users: int + guild_scheduled_events: List[ScheduledEvent] DefaultMessageNotificationLevel = Literal[0, 1] diff --git a/discord/types/invite.py b/discord/types/invite.py index 5af895ac0d..925b43986b 100644 --- a/discord/types/invite.py +++ b/discord/types/invite.py @@ -29,6 +29,7 @@ from .snowflake import Snowflake from .guild import InviteGuild, _GuildPreviewUnique +from .scheduled_events import ScheduledEvent from .channel import PartialChannel from .user import PartialUser from .appinfo import PartialAppInfo @@ -39,6 +40,7 @@ class _InviteOptional(TypedDict, total=False): guild: InviteGuild inviter: PartialUser + scheduled_event: ScheduledEvent target_user: PartialUser target_type: InviteTargetType target_application: PartialAppInfo diff --git a/discord/types/raw_models.py b/discord/types/raw_models.py index f7c1acf5a2..605fe161f0 100644 --- a/discord/types/raw_models.py +++ b/discord/types/raw_models.py @@ -106,3 +106,7 @@ class TypingEvent(_TypingEventOptional): timestamp: int +class ScheduledEventSubscription(TypedDict, total=False): + event_id: Snowflake + user_id: Snowflake + guild_id: Snowflake diff --git a/discord/types/scheduled_events.py b/discord/types/scheduled_events.py new file mode 100644 index 0000000000..3d4d2007c0 --- /dev/null +++ b/discord/types/scheduled_events.py @@ -0,0 +1,63 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import TypedDict, Optional, Literal, Union +from datetime import datetime + +from .guild import Guild +from .user import User +from .snowflake import Snowflake +from .channel import StageChannel, VoiceChannel +from .user import User +from .member import Member + + +ScheduledEventStatus = Literal[1, 2, 3, 4] +ScheduledEventLocationType = Literal[1, 2, 3] +ScheduledEventPrivacyLevel = Literal[2] + + +class ScheduledEventLocation(TypedDict): + value: Union[StageChannel, VoiceChannel, str] + type: ScheduledEventLocationType + + +class ScheduledEvent(TypedDict): + id: Snowflake + guild: Guild + name: str + description: str + #image: Optional[str] + start_time: datetime + end_time: Optional[datetime] + status: ScheduledEventStatus + subscriber_count: Optional[int] + creator_id: Snowflake + creator: Optional[User] + location: ScheduledEventLocation + +class ScheduledEventSubscriber(User): + member: Optional[Member] diff --git a/docs/api.rst b/docs/api.rst index 812d35f64a..559e2bdcac 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1161,6 +1161,83 @@ to handle it, which defaults to print a traceback and ignoring the exception. :param user: The user that joined or left. :type user: :class:`User` +.. function:: on_scheduled_event_create(event) + + Called when an :class:`ScheduledEvent` is created. + + This requires :attr:`Intents.scheduled_events` to be enabled. + + :param event: The newly created scheduled event. + :type event: :class:`ScheduledEvent` + +.. function:: on_scheduled_event_update(before, after) + + Called when a scheduled event is updated. + + This requires :attr:`Intents.scheduled_events` to be enabled. + + :param before: The old scheduled event. + :type before: :class:`ScheduledEvent` + :param after: The updated scheduled event. + :type after: :class:`ScheduledEvent` + +.. function:: on_scheduled_event_delete(event) + + Called when a scheduled event is deleted. + + This requires :attr:`Intents.scheduled_events` to be enabled. + + :param event: The deleted scheduled event. + :type event: :class:`ScheduledEvent` + +.. function:: on_scheduled_event_user_add(event, member) + + Called when a user subscribes to an event. If the member or event + is not found in the internal cache, then this event will not be + called. Consider using :func:`on_raw_scheduled_event_user_add` instead. + + This requires :attr:`Intents.scheduled_events` to be enabled. + + :param event: The scheduled event subscribed to. + :type event: :class:`ScheduledEvent` + :param member: The member who subscribed. + :type member: :class:`Member` + +.. function:: on_raw_scheduled_event_user_add(payload) + + Called when a user subscribes to an event. Unlike + :meth:`on_scheduled_event_user_add`, this will be called + regardless of the state of the internal cache. + + This requires :attr:`Intents.scheduled_events` to be enabled. + + :param payload: The raw event payload data. + :type payload: :class:`RawScheduledEventSubscription` + +.. function:: on_scheduled_event_user_remove(event, member) + + Called when a user unsubscribes to an event. If the member or event is + not found in the internal cache, then this event will not be called. + Consider using :func:`on_raw_scheduled_event_user_remove` instead. + + This requires :attr:`Intents.scheduled_events` to be enabled. + + :param event: The scheduled event unsubscribed from. + :type event: :class:`ScheduledEvent` + :param member: The member who unsubscribed. + :type member: :class:`Member` + +.. function:: on_raw_scheduled_event_user_remove(payload) + + Called when a user unsubscribes to an event. Unlike + :meth:`on_scheduled_event_user_remove`, this will be called + regardless of the state of the internal cache. + + This requires :attr:`Intents.scheduled_events` to be enabled. + + :param payload: The raw event payload data. + :type payload: :class:`RawScheduledEventSubscription` + .. _discord-api-utils: Utility Functions @@ -2848,8 +2925,61 @@ of :class:`enum.Enum`. .. attribute:: youtube_together Represents the embedded application Youtube Together. - - + +.. class:: ScheduledEventStatus + + Represents the status of a scheduled event. + + .. verssionadded:: 2.0 + + .. attribute:: scheduled + + The scheduled event hasn't started or been canceled yet. + + .. attribute:: active + + The scheduled event is in progress. + + .. attribute:: completed + + The scheduled event is over. + + .. attribute:: canceled + + The scheduled event has been canceled before it can start. + + .. attribute:: cancelled + + Alias to :attr:`canceled`. + +.. class:: ScheduledEventLocationType + + Represents a scheduled event location type (otherwise known as the entity type on the API). + + .. verssionadded:: 2.0 + + .. attribute:: stage_instance + + Represents a scheduled event that is happening in a :class:`StageChannel`. + + .. attribute:: voice + + Represents a scheduled event that is happening in a :class:`VoiceChannel`. + + .. attribute:: external + + Represents a generic location as a :class:`str`. + +.. class:: ScheduledEventPrivacyLevel + + Represents the privacy level of a scheduled event. + Scheduled event privacy levels can only have 1 possible value at the moment so + this shouldn't really be used. + + .. attribute:: guild_only + + Represents a scheduled event that is only available to members inside the guild. + Async Iterator ---------------- @@ -3770,6 +3900,21 @@ Guild :type: :class:`User` +ScheduledEvent +~~~~~~~~~~~~~~~ + +.. attributestable:: ScheduledEvent + +.. autoclass:: ScheduledEvent() + :members: + +ScheduledEventLocation +~~~~~~~~~~~~~~~~~~~~~~~ + +..attributestable:: ScheduledEventLocation + +.. autoclass:: ScheduledEventLocation() + :members: Integration ~~~~~~~~~~~~