diff --git a/discord/__init__.py b/discord/__init__.py index 1e74cf910..7b78e6c8f 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -56,6 +56,7 @@ from .raw_models import * from .team import * from .sticker import * from .stage_instance import * +from .scheduled_event import * from .interactions import * from .components import * from .threads import * diff --git a/discord/asset.py b/discord/asset.py index 968b8c628..34acad6ff 100644 --- a/discord/asset.py +++ b/discord/asset.py @@ -208,6 +208,15 @@ class Asset(AssetMixin): animated=False, ) + @classmethod + def _from_scheduled_event_cover_image(cls, state, scheduled_event_id: int, cover_image_hash: str) -> Asset: + return cls( + state, + url=f'{cls.BASE}/guild-events/{scheduled_event_id}/{cover_image_hash}.png?size=1024', + key=cover_image_hash, + animated=False, + ) + @classmethod def _from_guild_image(cls, state, guild_id: int, image: str, path: str) -> Asset: animated = image.startswith('a_') diff --git a/discord/audit_logs.py b/discord/audit_logs.py index f956908ab..7099113d5 100644 --- a/discord/audit_logs.py +++ b/discord/audit_logs.py @@ -229,12 +229,14 @@ class AuditLogChanges: 'tags': ('emoji', None), 'default_message_notifications': ('default_notifications', _enum_transformer(enums.NotificationLevel)), 'video_quality_mode': (None, _enum_transformer(enums.VideoQualityMode)), - 'privacy_level': (None, _enum_transformer(enums.StagePrivacyLevel)), + 'privacy_level': (None, _enum_transformer(enums.PrivacyLevel)), 'format_type': (None, _enum_transformer(enums.StickerFormatType)), 'type': (None, _transform_type), 'communication_disabled_until': ('timed_out_until', _transform_timestamp), 'expire_behavior': (None, _enum_transformer(enums.ExpireBehaviour)), 'mfa_level': (None, _enum_transformer(enums.MFALevel)), + 'status': (None, _enum_transformer(enums.EventStatus)), + 'entity_type': (None, _enum_transformer(enums.EntityType)), } # fmt: on diff --git a/discord/channel.py b/discord/channel.py index 2334197ee..6c9988f82 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -45,8 +45,9 @@ from typing import ( import datetime import discord.abc +from .scheduled_event import ScheduledEvent from .permissions import PermissionOverwrite, Permissions -from .enums import ChannelType, StagePrivacyLevel, try_enum, VideoQualityMode +from .enums import ChannelType, PrivacyLevel, try_enum, VideoQualityMode from .mixins import Hashable from .object import Object from . import utils @@ -950,6 +951,14 @@ class VocalGuildChannel(discord.abc.Connectable, discord.abc.GuildChannel, Hasha } # fmt: on + @property + def scheduled_events(self) -> List[ScheduledEvent]: + """List[:class:`ScheduledEvent`]: Returns all scheduled events for this channel. + + .. versionadded:: 2.0 + """ + return [event for event in self.guild.scheduled_events if event.channel_id == self.id] + @utils.copy_doc(discord.abc.GuildChannel.permissions_for) def permissions_for(self, obj: Union[Member, Role], /) -> Permissions: base = super().permissions_for(obj) @@ -1259,7 +1268,7 @@ class StageChannel(VocalGuildChannel): return utils.get(self.guild.stage_instances, channel_id=self.id) async def create_instance( - self, *, topic: str, privacy_level: StagePrivacyLevel = MISSING, reason: Optional[str] = None + self, *, topic: str, privacy_level: PrivacyLevel = MISSING, reason: Optional[str] = None ) -> StageInstance: """|coro| @@ -1274,8 +1283,8 @@ class StageChannel(VocalGuildChannel): ----------- topic: :class:`str` The stage instance's topic. - privacy_level: :class:`StagePrivacyLevel` - The stage instance's privacy level. Defaults to :attr:`StagePrivacyLevel.guild_only`. + privacy_level: :class:`PrivacyLevel` + The stage instance's privacy level. Defaults to :attr:`PrivacyLevel.guild_only`. reason: :class:`str` The reason the stage instance was created. Shows up on the audit log. @@ -1297,7 +1306,7 @@ class StageChannel(VocalGuildChannel): payload: Dict[str, Any] = {'channel_id': self.id, 'topic': topic} if privacy_level is not MISSING: - if not isinstance(privacy_level, StagePrivacyLevel): + if not isinstance(privacy_level, PrivacyLevel): raise TypeError('privacy_level field must be of type PrivacyLevel') payload['privacy_level'] = privacy_level.value diff --git a/discord/client.py b/discord/client.py index a986f5ee4..af494813a 100644 --- a/discord/client.py +++ b/discord/client.py @@ -1437,7 +1437,12 @@ class Client: # Invite management async def fetch_invite( - self, url: Union[Invite, str], *, with_counts: bool = True, with_expiration: bool = True + self, + url: Union[Invite, str], + *, + with_counts: bool = True, + with_expiration: bool = True, + scheduled_event_id: Optional[int] = None, ) -> Invite: """|coro| @@ -1462,9 +1467,20 @@ class Client: :attr:`.Invite.expires_at` field. .. versionadded:: 2.0 + scheduled_event_id: Optional[:class:`int`] + The ID of the scheduled event this invite is for. + + .. note:: + + It is not possible to provide a url that contains an ``event_id`` parameter + when using this parameter. + + .. versionadded:: 2.0 Raises ------- + ValueError + The url contains an ``event_id``, but ``scheduled_event_id`` has also been provided. NotFound The invite has expired or is invalid. HTTPException @@ -1476,8 +1492,19 @@ class Client: The invite from the URL/ID. """ - invite_id = utils.resolve_invite(url) - data = await self.http.get_invite(invite_id, with_counts=with_counts, with_expiration=with_expiration) + resolved = utils.resolve_invite(url) + + if scheduled_event_id and resolved.event: + raise ValueError('Cannot specify scheduled_event_id and contain an event_id in the url.') + + scheduled_event_id = scheduled_event_id or resolved.event + + data = await self.http.get_invite( + resolved.code, + with_counts=with_counts, + with_expiration=with_expiration, + guild_scheduled_event_id=scheduled_event_id, + ) return Invite.from_incomplete(state=self._connection, data=data) async def delete_invite(self, invite: Union[Invite, str], /) -> None: @@ -1507,8 +1534,8 @@ class Client: Revoking the invite failed. """ - invite_id = utils.resolve_invite(invite) - await self.http.delete_invite(invite_id) + resolved = utils.resolve_invite(invite) + await self.http.delete_invite(resolved.code) # Miscellaneous stuff diff --git a/discord/enums.py b/discord/enums.py index 522321fb0..0171f7341 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -51,12 +51,14 @@ __all__ = ( 'ComponentType', 'ButtonStyle', 'TextStyle', - 'StagePrivacyLevel', + 'PrivacyLevel', 'InteractionType', 'InteractionResponseType', 'NSFWLevel', 'MFALevel', 'Locale', + 'EntityType', + 'EventStatus', ) @@ -330,6 +332,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 thread_create = 110 thread_update = 111 thread_delete = 112 @@ -339,50 +344,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.thread_create: AuditLogActionCategory.create, - AuditLogAction.thread_update: AuditLogActionCategory.update, - AuditLogAction.thread_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, } # fmt: on return lookup[self] @@ -416,6 +424,8 @@ class AuditLogAction(Enum): return 'stage_instance' elif v < 93: return 'sticker' + elif v < 103: + return 'guild_scheduled_event' elif v < 113: return 'thread' @@ -568,9 +578,7 @@ class TextStyle(Enum): return self.value -class StagePrivacyLevel(Enum): - public = 1 - closed = 2 +class PrivacyLevel(Enum): guild_only = 2 @@ -625,6 +633,22 @@ class Locale(Enum): E = TypeVar('E', bound='Enum') +class EntityType(Enum): + stage_instance = 1 + voice = 2 + external = 3 + + +class EventStatus(Enum): + scheduled = 1 + active = 2 + completed = 3 + canceled = 4 + + ended = 3 + cancelled = 4 + + def create_unknown_value(cls: Type[E], val: Any) -> E: value_cls = cls._enum_value_cls_ # type: ignore - This is narrowed below name = f'unknown_{val}' diff --git a/discord/flags.py b/discord/flags.py index 22f8425c4..975e2ae19 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -938,6 +938,22 @@ class Intents(BaseFlags): """ return 1 << 15 + @flag_value + def guild_scheduled_events(self): + """:class:`bool`: Whether guild 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_scheduled_event_user_remove` + + .. versionadded:: 2.0 + """ + return 1 << 16 + @fill_with_flags() class MemberCacheFlags(BaseFlags): diff --git a/discord/guild.py b/discord/guild.py index 64a03428b..0327efe1d 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -60,6 +60,8 @@ from .enums import ( AuditLogAction, VideoQualityMode, ChannelType, + EntityType, + PrivacyLevel, try_enum, VerificationLevel, ContentFilter, @@ -74,6 +76,7 @@ from .widget import Widget from .asset import Asset from .flags import SystemChannelFlags from .integrations import Integration, _integration_factory +from .scheduled_event import ScheduledEvent from .stage_instance import StageInstance from .threads import Thread, ThreadMember from .sticker import GuildSticker @@ -293,6 +296,7 @@ class Guild(Hashable): '_rules_channel_id', '_public_updates_channel_id', '_stage_instances', + '_scheduled_events', '_threads', ) @@ -466,6 +470,11 @@ class Guild(Hashable): stage_instance = StageInstance(guild=self, data=s, state=state) self._stage_instances[stage_instance.id] = stage_instance + self._scheduled_events: Dict[int, ScheduledEvent] = {} + for s in guild.get('guild_scheduled_events', []): + scheduled_event = ScheduledEvent(data=s, state=state) + self._scheduled_events[scheduled_event.id] = scheduled_event + cache_joined = self._state.member_cache_flags.joined self_id = self._state.self_id for mdata in guild.get('members', []): @@ -867,6 +876,31 @@ class Guild(Hashable): """ return self._stage_instances.get(stage_instance_id) + @property + def scheduled_events(self) -> List[ScheduledEvent]: + """List[:class:`ScheduledEvent`]: Returns a :class:`list` of the guild's scheduled events. + + .. versionadded:: 2.0 + """ + return list(self._scheduled_events.values()) + + def get_scheduled_event(self, scheduled_event_id: int, /) -> Optional[ScheduledEvent]: + """Returns a scheduled event with the given ID. + + .. versionadded:: 2.0 + + Parameters + ----------- + scheduled_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(scheduled_event_id) + @property def owner(self) -> Optional[Member]: """Optional[:class:`Member`]: The member that owns the guild.""" @@ -2413,6 +2447,190 @@ class Guild(Hashable): """ await self._state.http.delete_guild_sticker(self.id, sticker.id, reason) + async def fetch_scheduled_events(self, *, with_counts: bool = True) -> List[ScheduledEvent]: + """|coro| + + Retrieves a list of all scheduled events for the guild. + + .. versionadded:: 2.0 + + Parameters + ------------ + with_counts: :class:`bool` + Whether to include the number of users that are subscribed to the event. + Defaults to ``True``. + + Raises + ------- + HTTPException + Retrieving the scheduled events failed. + + Returns + -------- + List[:class:`ScheduledEvent`] + The scheduled events. + """ + data = await self._state.http.get_scheduled_events(self.id, with_counts) + return [ScheduledEvent(state=self._state, data=d) for d in data] + + async def fetch_scheduled_event(self, scheduled_event_id: int, /, *, with_counts: bool = True) -> ScheduledEvent: + """|coro| + + Retrieves a scheduled event from the guild. + + .. versionadded:: 2.0 + + Parameters + ------------ + id: :class:`int` + The scheduled event ID. + with_counts: :class:`bool` + Whether to include the number of users that are subscribed to the event. + Defaults to ``True``. + + Raises + ------- + NotFound + The scheduled event was not found. + HTTPException + Retrieving the scheduled event failed. + + Returns + -------- + :class:`ScheduledEvent` + The scheduled event. + """ + data = await self._state.http.get_scheduled_event(self.id, scheduled_event_id, with_counts) + return ScheduledEvent(state=self._state, data=data) + + async def create_scheduled_event( + self, + *, + name: str, + start_time: datetime.datetime, + entity_type: EntityType, + privacy_level: PrivacyLevel = MISSING, + channel: Optional[Snowflake] = MISSING, + location: str = MISSING, + end_time: datetime.datetime = MISSING, + description: str = MISSING, + image: bytes = MISSING, + reason: Optional[str] = None, + ) -> ScheduledEvent: + r"""|coro| + + Creates a scheduled event for the guild. + + Requires :attr:`~Permissions.manage_events` permissions. + + .. versionadded:: 2.0 + + Parameters + ------------ + name: :class:`str` + The name of the scheduled event. + description: :class:`str` + The description of the scheduled event. + channel: Optional[:class:`abc.Snowflake`] + The channel to send the scheduled event to. + + Required if ``entity_type`` is either :attr:`EntityType.voice` or + :attr:`EntityType.stage_instance`. + start_time: :class:`datetime.datetime` + The scheduled start time of the scheduled event. This must be a timezone-aware + datetime object. Consider using :func:`utils.utcnow`. + end_time: :class:`datetime.datetime` + The scheduled end time of the scheduled event. This must be a timezone-aware + datetime object. Consider using :func:`utils.utcnow`. + + Required if the entity type is :attr:`EntityType.external`. + entity_type: :class:`EntityType` + The entity type of the scheduled event. + image: :class:`bytes` + The image of the scheduled event. + location: :class:`str` + The location of the scheduled event. + + Required if the ``entity_type`` is :attr:`EntityType.external`. + reason: Optional[:class:`str`] + The reason for creating this scheduled event. Shows up on the audit log. + + Raises + ------- + TypeError + `image` was not a :term:`py:bytes-like object`, or ``privacy_level`` + was not a :class:`PrivacyLevel`, or ``entity_type`` was not an + :class:`EntityType`, ``status`` was not an :class:`EventStatus`, + or an argument was provided that was incompatible with the provided + ``entity_type``. + ValueError + ``start_time`` or ``end_time`` was not a timezone-aware datetime object. + Forbidden + You are not allowed to create scheduled events. + HTTPException + Creating the scheduled event failed. + + Returns + -------- + :class:`ScheduledEvent` + The created scheduled event. + """ + payload = {} + metadata = {} + + payload['name'] = name + + if start_time is not MISSING: + if start_time.tzinfo is None: + raise ValueError( + 'start_time must be an aware datetime. Consider using discord.utils.utcnow() or datetime.datetime.now().astimezone() for local time.' + ) + payload['scheduled_start_time'] = start_time.isoformat() + + if not isinstance(entity_type, EntityType): + raise TypeError('entity_type must be of type EntityType') + + payload['entity_type'] = entity_type.value + + payload['privacy_level'] = PrivacyLevel.guild_only.value + + if description is not MISSING: + payload['description'] = description + + if image is not MISSING: + image_as_str: str = utils._bytes_to_base64_data(image) + payload['image'] = image_as_str + + if entity_type in (EntityType.stage_instance, EntityType.voice): + if channel is MISSING or channel is None: + raise TypeError('channel must be set when entity_type is voice or stage_instance') + + payload['channel_id'] = channel.id + + if location is not MISSING: + raise TypeError('location cannot be set when entity_type is voice or stage_instance') + else: + if channel is not MISSING: + raise TypeError('channel cannot be set when entity_type is external') + + if location is MISSING or location is None: + raise TypeError('location must be set when entity_type is external') + + metadata['location'] = location + + if end_time is not MISSING: + if end_time.tzinfo is None: + raise ValueError( + 'end_time must be an aware datetime. Consider using discord.utils.utcnow() or datetime.datetime.now().astimezone() for local time.' + ) + payload['scheduled_end_time'] = end_time.isoformat() + + if metadata: + payload['entity_metadata'] = metadata + + data = await self._state.http.create_guild_scheduled_event(self.id, **payload, reason=reason) + return ScheduledEvent(state=self._state, data=data) + async def fetch_emojis(self) -> List[Emoji]: r"""|coro| diff --git a/discord/http.py b/discord/http.py index eec73d9a6..c8783e374 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1426,12 +1426,21 @@ class HTTPClient: 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: Optional[Snowflake] = None, ) -> Response[invite.Invite]: - params = { + params: Dict[str, Any] = { 'with_counts': int(with_counts), 'with_expiration': int(with_expiration), } + + if guild_scheduled_event_id: + params['guild_scheduled_event_id'] = 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]]: @@ -1583,8 +1592,16 @@ class HTTPClient: ) -> Response[List[scheduled_event.GuildScheduledEvent]]: ... + @overload + def get_scheduled_events( + self, guild_id: Snowflake, with_user_count: bool + ) -> Union[ + Response[List[scheduled_event.GuildScheduledEventWithUserCount]], Response[List[scheduled_event.GuildScheduledEvent]] + ]: + ... + def get_scheduled_events(self, guild_id: Snowflake, with_user_count: bool) -> Response[Any]: - params = {'with_user_count': with_user_count} + 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 create_guild_scheduled_event( @@ -1619,10 +1636,16 @@ class HTTPClient: ) -> Response[scheduled_event.GuildScheduledEvent]: ... + @overload + def get_scheduled_event( + self, guild_id: Snowflake, guild_scheduled_event_id: Snowflake, with_user_count: bool + ) -> Union[Response[scheduled_event.GuildScheduledEventWithUserCount], Response[scheduled_event.GuildScheduledEvent]]: + ... + def get_scheduled_event( self, guild_id: Snowflake, guild_scheduled_event_id: Snowflake, with_user_count: bool ) -> Response[Any]: - params = {'with_user_count': with_user_count} + params = {'with_user_count': int(with_user_count)} return self.request( Route( 'GET', @@ -1643,6 +1666,7 @@ class HTTPClient: 'privacy_level', 'scheduled_start_time', 'scheduled_end_time', + 'status', 'description', 'entity_type', 'image', @@ -1664,6 +1688,8 @@ class HTTPClient: self, guild_id: Snowflake, guild_scheduled_event_id: Snowflake, + *, + reason: Optional[str] = None, ) -> Response[None]: return self.request( Route( @@ -1672,10 +1698,11 @@ class HTTPClient: guild_id=guild_id, guild_scheduled_event_id=guild_scheduled_event_id, ), + reason=reason, ) @overload - def get_scheduled_users( + def get_scheduled_event_users( self, guild_id: Snowflake, guild_scheduled_event_id: Snowflake, @@ -1683,11 +1710,11 @@ class HTTPClient: with_member: Literal[True], before: Optional[Snowflake] = ..., after: Optional[Snowflake] = ..., - ) -> Response[scheduled_event.ScheduledEventUserWithMember]: + ) -> Response[scheduled_event.ScheduledEventUsersWithMember]: ... @overload - def get_scheduled_users( + def get_scheduled_event_users( self, guild_id: Snowflake, guild_scheduled_event_id: Snowflake, @@ -1695,10 +1722,22 @@ class HTTPClient: with_member: Literal[False], before: Optional[Snowflake] = ..., after: Optional[Snowflake] = ..., - ) -> Response[scheduled_event.ScheduledEventUser]: + ) -> Response[scheduled_event.ScheduledEventUsers]: + ... + + @overload + def get_scheduled_event_users( + self, + guild_id: Snowflake, + guild_scheduled_event_id: Snowflake, + limit: int, + with_member: bool, + before: Optional[Snowflake] = ..., + after: Optional[Snowflake] = ..., + ) -> Union[Response[scheduled_event.ScheduledEventUsersWithMember], Response[scheduled_event.ScheduledEventUsers]]: ... - def get_scheduled_users( + def get_scheduled_event_users( self, guild_id: Snowflake, guild_scheduled_event_id: Snowflake, @@ -1709,7 +1748,7 @@ class HTTPClient: ) -> Response[Any]: params: Dict[str, Any] = { 'limit': limit, - 'with_member': with_member, + 'with_member': int(with_member), } if before is not None: diff --git a/discord/invite.py b/discord/invite.py index d02fa6808..0e7fc9ee6 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -31,6 +31,7 @@ from .object import Object from .mixins import Hashable from .enums import ChannelType, VerificationLevel, InviteTarget, try_enum from .appinfo import PartialAppInfo +from .scheduled_event import ScheduledEvent __all__ = ( 'PartialInviteChannel', @@ -51,6 +52,7 @@ if TYPE_CHECKING: from .guild import Guild from .abc import GuildChannel from .user import User + from .abc import Snowflake InviteGuildType = Union[Guild, 'PartialInviteGuild', Object] InviteChannelType = Union[GuildChannel, 'PartialInviteChannel', Object] @@ -303,6 +305,14 @@ class Invite(Hashable): target_application: Optional[:class:`PartialAppInfo`] The embedded application the invite targets, if any. + .. versionadded:: 2.0 + scheduled_event: Optional[:class:`ScheduledEvent`] + The scheduled event associated with this invite, if any. + + .. versionadded:: 2.0 + scheduled_event_id: Optional[:class:`int`] + The ID of the scheduled event associated with this invite, if any. + .. versionadded:: 2.0 """ @@ -324,6 +334,8 @@ class Invite(Hashable): 'approximate_presence_count', 'target_application', 'expires_at', + 'scheduled_event', + 'scheduled_event_id', ) BASE = 'https://discord.gg' @@ -366,6 +378,17 @@ class Invite(Hashable): PartialAppInfo(data=application, state=state) if application else None ) + scheduled_event = data.get('guild_scheduled_event') + self.scheduled_event: Optional[ScheduledEvent] = ( + ScheduledEvent( + state=self._state, + data=scheduled_event, + ) + if scheduled_event + else None + ) + self.scheduled_event_id: Optional[int] = self.scheduled_event.id if self.scheduled_event else None + @classmethod def from_incomplete(cls: Type[I], *, state: ConnectionState, data: InvitePayload) -> I: guild: Optional[Union[Guild, PartialInviteGuild]] @@ -451,7 +474,33 @@ class Invite(Hashable): @property def url(self) -> str: """:class:`str`: A property that retrieves the invite URL.""" - return self.BASE + '/' + self.code + url = self.BASE + '/' + self.code + if self.scheduled_event_id is not None: + url += '?event=' + str(self.scheduled_event_id) + return url + + def set_scheduled_event(self: I, scheduled_event: Snowflake, /) -> I: + """Sets the scheduled event for this invite. + + .. versionadded:: 2.0 + + Parameters + ---------- + scheduled_event: :class:`~discord.abc.Snowflake` + The ID of the scheduled event. + + Returns + -------- + :class:`Invite` + The invite with the new scheduled event. + """ + self.scheduled_event_id = scheduled_event.id + try: + self.scheduled_event = self.guild.get_scheduled_event(scheduled_event.id) # type: ignore - handled below + except AttributeError: + self.scheduled_event = None + + return self async def delete(self, *, reason: Optional[str] = None): """|coro| diff --git a/discord/scheduled_event.py b/discord/scheduled_event.py new file mode 100644 index 000000000..9ee69c08f --- /dev/null +++ b/discord/scheduled_event.py @@ -0,0 +1,549 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +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 datetime import datetime +from typing import TYPE_CHECKING, AsyncIterator, Dict, Optional, Union + +from .asset import Asset +from .enums import EventStatus, EntityType, PrivacyLevel, try_enum +from .mixins import Hashable +from .object import Object, OLDEST_OBJECT +from .utils import parse_time, _get_as_snowflake, _bytes_to_base64_data, MISSING + +if TYPE_CHECKING: + from .types.scheduled_event import ( + GuildScheduledEvent as GuildScheduledEventPayload, + GuildScheduledEventWithUserCount as GuildScheduledEventWithUserCountPayload, + EntityMetadata, + ) + + from .abc import Snowflake + from .channel import VoiceChannel, StageChannel + from .state import ConnectionState + from .user import User + + GuildScheduledEventPayload = Union[GuildScheduledEventPayload, GuildScheduledEventWithUserCountPayload] + +# fmt: off +__all__ = ( + "ScheduledEvent", +) +# fmt: on + + +class ScheduledEvent(Hashable): + """Represents a scheduled event in a guild. + + .. versionadded:: 2.0 + + .. 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. + + Attributes + ---------- + id: :class:`int` + The scheduled event's ID. + name: :class:`str` + The name of the scheduled event. + description: :class:`str` + The description of the scheduled event. + entity_type: :class:`EntityType` + The type of entity this event is for. + entity_id: :class:`int` + The ID of the entity this event is for. + start_time: :class:`datetime.datetime` + The time that the scheduled event will start in UTC. + end_time: :class:`datetime.datetime` + The time that the scheduled event will end in UTC. + privacy_level: :class:`PrivacyLevel` + The privacy level of the scheduled event. + status: :class:`EventStatus` + The status of the scheduled event. + user_count: :class:`int` + The number of users subscribed to the scheduled event. + creator: Optional[:class:`User`] + The user that created the scheduled event. + location: Optional[:class:`str`] + The location of the scheduled event. + """ + + __slots__ = ( + '_state', + '_users', + 'id', + 'guild_id', + 'name', + 'description', + 'entity_type', + 'entity_id', + 'start_time', + 'end_time', + 'privacy_level', + 'status', + '_cover_image', + 'user_count', + 'creator', + 'channel_id', + 'location', + ) + + def __init__(self, *, state: ConnectionState, data: GuildScheduledEventPayload) -> None: + self._state = state + self._users: Dict[int, User] = {} + self._update(data) + + def _update(self, data: GuildScheduledEventPayload) -> None: + self.id: int = int(data['id']) + self.guild_id: int = int(data['guild_id']) + self.name: str = data['name'] + self.description: str = data.get('description', '') + self.entity_type = try_enum(EntityType, data['entity_type']) + self.entity_id: int = int(data['id']) + self.start_time: datetime = parse_time(data['scheduled_start_time']) + self.privacy_level: PrivacyLevel = try_enum(PrivacyLevel, data['status']) + self.status: EventStatus = try_enum(EventStatus, data['status']) + self._cover_image: Optional[str] = data['image'] + self.user_count: int = data.get('user_count', 0) + + creator = data.get('creator') + self.creator: Optional[User] = self._state.store_user(creator) if creator else None + + self.end_time: Optional[datetime] = parse_time(data.get('scheduled_end_time')) + self.channel_id: Optional[int] = _get_as_snowflake(data, 'channel_id') + + metadata = data.get('metadata') + if metadata: + self._unroll_metadata(metadata) + + def _unroll_metadata(self, data: EntityMetadata): + self.location: Optional[str] = data.get('location') + + @classmethod + def from_creation(cls, *, state: ConnectionState, data: GuildScheduledEventPayload): + creator_id = data.get('creator_id') + self = cls(state=state, data=data) + if creator_id: + self.creator = self._state.get_user(int(creator_id)) + + def __repr__(self) -> str: + return f'' + + @property + def cover_image(self) -> Optional[Asset]: + """Optional[:class:`Asset`]: The scheduled event's cover image.""" + if self._cover_image is None: + return None + return Asset._from_scheduled_event_cover_image(self._state, self.id, self._cover_image) + + @property + def channel(self) -> Optional[Union[VoiceChannel, StageChannel]]: + """Optional[Union[:class:`VoiceChannel`, :class:`StageChannel`]]: The channel this scheduled event is in.""" + return self.guild.get_channel(self.channel_id) # type: ignore + + async def start(self, *, reason: Optional[str] = None) -> ScheduledEvent: + """|coro| + + Starts the scheduled event. + + Shorthand for: + + .. code-block:: python3 + + await event.edit(status=EventStatus.active) + + Parameters + ----------- + reason: Optional[:class:`str`] + The reason for starting the scheduled event. + + Raises + ------ + ValueError + The scheduled event has already started or has ended. + Forbidden + You do not have the proper permissions to start the scheduled event. + HTTPException + The scheduled event could not be started. + + Returns + ------- + :class:`ScheduledEvent` + The scheduled event that was started. + """ + if self.status is not EventStatus.scheduled: + raise ValueError('This scheduled event is already running.') + + return await self.edit(status=EventStatus.active, reason=reason) + + async def end(self, *, reason: Optional[str] = None) -> ScheduledEvent: + """|coro| + + Ends the scheduled event. + + Shorthand for: + + .. code-block:: python3 + + await event.edit(status=EventStatus.completed) + + Parameters + ----------- + reason: Optional[:class:`str`] + The reason for ending the scheduled event. + + Raises + ------ + ValueError + The scheduled event is not active or has already ended. + Forbidden + You do not have the proper permissions to end the scheduled event. + HTTPException + The scheduled event could not be ended. + + Returns + ------- + :class:`ScheduledEvent` + The scheduled event that was ended. + """ + if self.status is not EventStatus.active: + raise ValueError('This scheduled event is not active.') + + return await self.edit(status=EventStatus.ended, reason=reason) + + async def cancel(self, *, reason: Optional[str] = None) -> ScheduledEvent: + """|coro| + + Cancels the scheduled event. + + Shorthand for: + + .. code-block:: python3 + + await event.edit(status=EventStatus.cancelled) + + Parameters + ----------- + reason: Optional[:class:`str`] + The reason for cancelling the scheduled event. + + Raises + ------ + ValueError + The scheduled event is already running. + Forbidden + You do not have the proper permissions to cancel the scheduled event. + HTTPException + The scheduled event could not be cancelled. + + Returns + ------- + :class:`ScheduledEvent` + The scheduled event that was cancelled. + """ + if self.status is not EventStatus.scheduled: + raise ValueError('This scheduled event is already running.') + + return await self.edit(status=EventStatus.cancelled, reason=reason) + + async def edit( + self, + *, + name: str = MISSING, + description: str = MISSING, + channel: Optional[Snowflake] = MISSING, + start_time: datetime = MISSING, + end_time: datetime = MISSING, + privacy_level: PrivacyLevel = MISSING, + entity_type: EntityType = MISSING, + status: EventStatus = MISSING, + image: bytes = MISSING, + location: str = MISSING, + reason: Optional[str] = None, + ) -> ScheduledEvent: + r"""|coro| + + Edits the scheduled event. + + Requires :attr:`~Permissions.manage_events` permissions. + + Parameters + ----------- + name: :class:`str` + The name of the scheduled event. + description: :class:`str` + The description of the scheduled event. + channel: Optional[:class:`~discord.abc.Snowflake`] + The channel to put the scheduled event in. + + Required if the entity type is either :attr:`EntityType.voice` or + :attr:`EntityType.stage_instance`. + start_time: :class:`datetime.datetime` + The time that the scheduled event will start. This must be a timezone-aware + datetime object. Consider using :func:`utils.utcnow`. + end_time: :class:`datetime.datetime` + The time that the scheduled event will end. This must be a timezone-aware + datetime object. Consider using :func:`utils.utcnow`. + + Required if the entity type is :attr:`EntityType.external`. + privacy_level: :class:`PrivacyLevel` + The privacy level of the scheduled event. + entity_type: :class:`EntityType` + The new entity type. + status: :class:`EventStatus` + The new status of the scheduled event. + image: :class:`bytes` + The new image of the scheduled event. + location: :class:`str` + The new location of the scheduled event. + + Required if the entity type is :attr:`EntityType.external`. + reason: Optional[:class:`str`] + The reason for editing the scheduled event. Shows up on the audit log. + + Raises + ------- + TypeError + `image` was not a :term:`py:bytes-like object`, or ``privacy_level`` + was not a :class:`PrivacyLevel`, or ``entity_type`` was not an + :class:`EntityType`, ``status`` was not an :class:`EventStatus`, or + an argument was provided that was incompatible with the scheduled event's + entity type. + ValueError + ``start_time`` or ``end_time`` was not a timezone-aware datetime object. + Forbidden + You do not have permissions to edit the scheduled event. + HTTPException + Editing the scheduled event failed. + + Returns + -------- + :class:`ScheduledEvent` + The edited scheduled event. + """ + payload = {} + metadata = {} + + if name is not MISSING: + payload['name'] = name + + if start_time is not MISSING: + if start_time.tzinfo is None: + raise ValueError( + 'start_time must be an aware datetime. Consider using discord.utils.utcnow() or datetime.datetime.now().astimezone() for local time.' + ) + payload['scheduled_start_time'] = start_time.isoformat() + + if description is not MISSING: + payload['description'] = description + + if privacy_level is not MISSING: + if not isinstance(privacy_level, PrivacyLevel): + raise TypeError('privacy_level must be of type PrivacyLevel.') + + payload['privacy_level'] = privacy_level.value + + if status is not MISSING: + if not isinstance(status, EventStatus): + raise TypeError('status must be of type EventStatus') + + payload['status'] = status.value + + if image is not MISSING: + image_as_str: str = _bytes_to_base64_data(image) + payload['image'] = image_as_str + + if entity_type is not MISSING: + if not isinstance(entity_type, EntityType): + raise TypeError('entity_type must be of type EntityType') + + payload['entity_type'] = entity_type.value + + _entity_type = entity_type or self.entity_type + + if _entity_type in (EntityType.stage_instance, EntityType.voice): + if channel is MISSING or channel is None: + raise TypeError('channel must be set when entity_type is voice or stage_instance') + + payload['channel_id'] = channel.id + + if location is not MISSING: + raise TypeError('location cannot be set when entity_type is voice or stage_instance') + else: + if channel is not MISSING: + raise TypeError('channel cannot be set when entity_type is external') + + if location is MISSING or location is None: + raise TypeError('location must be set when entity_type is external') + + metadata['location'] = location + + if end_time is MISSING: + raise TypeError('end_time must be set when entity_type is external') + + if end_time.tzinfo is None: + raise ValueError( + 'end_time must be an aware datetime. Consider using discord.utils.utcnow() or datetime.datetime.now().astimezone() for local time.' + ) + payload['scheduled_end_time'] = end_time.isoformat() + + if metadata: + payload['entity_metadata'] = metadata + + data = await self._state.http.edit_scheduled_event(self.guild_id, self.id, **payload, reason=reason) + s = ScheduledEvent(state=self._state, data=data) + s._users = self._users + return s + + async def delete(self, *, reason: Optional[str] = None) -> None: + """|coro| + + Deletes the scheduled event. + + Requires :attr:`~Permissions.manage_events` permissions. + + Parameters + ----------- + reason: Optional[:class:`str`] + The reason for deleting the scheduled event. Shows up on the audit log. + + Raises + ------ + Forbidden + You do not have permissions to delete the scheduled event. + HTTPException + Deleting the scheduled event failed. + """ + await self._state.http.delete_scheduled_event(self.guild_id, self.id, reason=reason) + + async def users( + self, + *, + limit: Optional[int] = None, + before: Optional[Snowflake] = None, + after: Optional[Snowflake] = None, + oldest_first: bool = MISSING, + ) -> AsyncIterator[User]: + """|coro| + + Retrieves all :class:`User` that are in this thread. + + This requires :attr:`Intents.members` to get information about members + other than yourself. + + Raises + ------- + HTTPException + Retrieving the members failed. + + Returns + -------- + List[:class:`User`] + All thread members in the thread. + """ + + async def _before_strategy(retrieve, before, limit): + before_id = before.id if before else None + users = await self._state.http.get_scheduled_event_users( + self.guild_id, self.id, limit=retrieve, with_member=False, before=before_id + ) + + if users: + if limit is not None: + limit -= len(users) + + before = Object(id=users[-1]['user']['id']) + + return users, before, limit + + async def _after_strategy(retrieve, after, limit): + after_id = after.id if after else None + users = await self._state.http.get_scheduled_event_users( + self.guild_id, self.id, limit=retrieve, with_member=False, after=after_id + ) + + if users: + if limit is not None: + limit -= len(users) + + after = Object(id=users[0]['user']['id']) + + return users, after, limit + + if limit is None: + limit = self.user_count or None + + if oldest_first is MISSING: + reverse = after is not None + else: + reverse = oldest_first + + predicate = None + + if reverse: + strategy, state = _after_strategy, after + if before: + predicate = lambda u: u['user']['id'] < before.id + else: + strategy, state = _before_strategy, before + if after and after != OLDEST_OBJECT: + predicate = lambda u: u['user']['id'] > after.id + + while True: + retrieve = min(100 if limit is None else limit, 100) + if retrieve < 1: + return + + data, state, limit = await strategy(retrieve, state, limit) + + if len(data) < 100: + limit = 0 + + if reverse: + data = reversed(data) + if predicate: + data = filter(predicate, data) + + users = (self._state.store_user(raw_user['user']) for raw_user in data) + + for user in users: + yield user + + def _add_user(self, user: User) -> None: + self._users[user.id] = user + + def _pop_user(self, user_id: int) -> None: + self._users.pop(user_id) diff --git a/discord/stage_instance.py b/discord/stage_instance.py index 43bae022f..2a23dad74 100644 --- a/discord/stage_instance.py +++ b/discord/stage_instance.py @@ -28,7 +28,7 @@ from typing import Optional, TYPE_CHECKING from .utils import MISSING, cached_slot_property from .mixins import Hashable -from .enums import StagePrivacyLevel, try_enum +from .enums import PrivacyLevel, try_enum # fmt: off __all__ = ( @@ -72,7 +72,7 @@ class StageInstance(Hashable): The ID of the channel that the stage instance is running in. topic: :class:`str` The topic of the stage instance. - privacy_level: :class:`StagePrivacyLevel` + privacy_level: :class:`PrivacyLevel` The privacy level of the stage instance. discoverable_disabled: :class:`bool` Whether discoverability for the stage instance is disabled. @@ -98,7 +98,7 @@ class StageInstance(Hashable): self.id: int = int(data['id']) self.channel_id: int = int(data['channel_id']) self.topic: str = data['topic'] - self.privacy_level: StagePrivacyLevel = try_enum(StagePrivacyLevel, data['privacy_level']) + self.privacy_level: PrivacyLevel = try_enum(PrivacyLevel, data['privacy_level']) self.discoverable_disabled: bool = data.get('discoverable_disabled', False) def __repr__(self) -> str: @@ -110,14 +110,11 @@ class StageInstance(Hashable): # the returned channel will always be a StageChannel or None return self._state.get_channel(self.channel_id) # type: ignore - def is_public(self) -> bool: - return self.privacy_level is StagePrivacyLevel.public - async def edit( self, *, topic: str = MISSING, - privacy_level: StagePrivacyLevel = MISSING, + privacy_level: PrivacyLevel = MISSING, reason: Optional[str] = None, ) -> None: """|coro| @@ -131,7 +128,7 @@ class StageInstance(Hashable): ----------- topic: :class:`str` The stage instance's new topic. - privacy_level: :class:`StagePrivacyLevel` + privacy_level: :class:`PrivacyLevel` The stage instance's new privacy level. reason: :class:`str` The reason the stage instance was edited. Shows up on the audit log. @@ -152,7 +149,7 @@ class StageInstance(Hashable): payload['topic'] = topic if privacy_level is not MISSING: - if not isinstance(privacy_level, StagePrivacyLevel): + if not isinstance(privacy_level, PrivacyLevel): raise TypeError('privacy_level field must be of type PrivacyLevel') payload['privacy_level'] = privacy_level.value diff --git a/discord/state.py b/discord/state.py index b7b93dc95..614b7d2c6 100644 --- a/discord/state.py +++ b/discord/state.py @@ -56,6 +56,7 @@ from .invite import Invite from .integrations import _integration_factory from .interactions import Interaction from .ui.view import ViewStore, View +from .scheduled_event import ScheduledEvent from .stage_instance import StageInstance from .threads import Thread, ThreadMember from .sticker import GuildSticker @@ -744,6 +745,12 @@ class ConnectionState: guild._remove_channel(channel) self.dispatch('guild_channel_delete', channel) + if channel.type in (ChannelType.voice, ChannelType.stage_voice): + for s in guild.scheduled_events: + if s.channel_id == channel.id: + guild._scheduled_events.pop(s.id) + self.dispatch('scheduled_event_delete', guild, s) + def parse_channel_update(self, data: gw.ChannelUpdateEvent) -> None: channel_type = try_enum(ChannelType, data.get('type')) channel_id = int(data['id']) @@ -1286,6 +1293,80 @@ class ConnectionState: else: _log.debug('STAGE_INSTANCE_DELETE referencing unknown guild ID: %s. Discarding.', data['guild_id']) + def parse_guild_scheduled_event_create(self, data: gw.GuildScheduledEventCreateEvent) -> None: + guild = self._get_guild(int(data['guild_id'])) + if guild is not None: + scheduled_event = ScheduledEvent(state=self, data=data) + guild._scheduled_events[scheduled_event.id] = scheduled_event + self.dispatch('scheduled_event_create', guild, scheduled_event) + else: + _log.debug('SCHEDULED_EVENT_CREATE referencing unknown guild ID: %s. Discarding.', data['guild_id']) + + def parse_guild_scheduled_event_update(self, data: gw.GuildScheduledEventUpdateEvent) -> None: + guild = self._get_guild(int(data['guild_id'])) + if guild is not None: + scheduled_event = guild._scheduled_events.get(int(data['id'])) + if scheduled_event is not None: + old_scheduled_event = copy.copy(scheduled_event) + scheduled_event._update(data) + self.dispatch('scheduled_event_update', guild, old_scheduled_event, scheduled_event) + else: + _log.debug('SCHEDULED_EVENT_UPDATE referencing unknown scheduled event ID: %s. Discarding.', data['id']) + else: + _log.debug('SCHEDULED_EVENT_UPDATE referencing unknown guild ID: %s. Discarding.', data['guild_id']) + + def parse_guild_scheduled_event_delete(self, data: gw.GuildScheduledEventDeleteEvent) -> None: + guild = self._get_guild(int(data['guild_id'])) + if guild is not None: + try: + scheduled_event = guild._scheduled_events.pop(int(data['id'])) + except KeyError: + pass + else: + self.dispatch('scheduled_event_delete', guild, scheduled_event) + else: + _log.debug('SCHEDULED_EVENT_DELETE referencing unknown guild ID: %s. Discarding.', data['guild_id']) + + def parse_guild_scheduled_event_user_add(self, data: gw.GuildScheduledEventUserAdd) -> None: + guild = self._get_guild(int(data['guild_id'])) + if guild is not None: + scheduled_event = guild._scheduled_events.get(int(data['guild_scheduled_event_id'])) + if scheduled_event is not None: + user = self.get_user(int(data['user_id'])) + if user is not None: + scheduled_event._add_user(user) + self.dispatch('scheduled_event_user_add', guild, scheduled_event, user) + else: + _log.debug('SCHEDULED_EVENT_USER_ADD referencing unknown user ID: %s. Discarding.', data['user_id']) + self.dispatch('scheduled_event_user_add', guild, scheduled_event, user) + else: + _log.debug( + 'SCHEDULED_EVENT_USER_ADD referencing unknown scheduled event ID: %s. Discarding.', + data['guild_scheduled_event_id'], + ) + else: + _log.debug('SCHEDULED_EVENT_USER_ADD referencing unknown guild ID: %s. Discarding.', data['guild_id']) + + def parse_guild_scheduled_event_user_remove(self, data: gw.GuildScheduledEventUserRemove) -> None: + guild = self._get_guild(int(data['guild_id'])) + if guild is not None: + scheduled_event = guild._scheduled_events.get(int(data['guild_scheduled_event_id'])) + if scheduled_event is not None: + user = self.get_user(int(data['user_id'])) + if user is not None: + scheduled_event._pop_user(user.id) + self.dispatch('scheduled_event_user_remove', scheduled_event, user) + else: + _log.debug('SCHEDULED_EVENT_USER_REMOVE referencing unknown user ID: %s. Discarding.', data['user_id']) + self.dispatch('scheduled_event_user_remove', scheduled_event, user) + else: + _log.debug( + 'SCHEDULED_EVENT_USER_REMOVE referencing unknown scheduled event ID: %s. Discarding.', + data['guild_scheduled_event_id'], + ) + else: + _log.debug('SCHEDULED_EVENT_USER_REMOVE referencing unknown guild ID: %s. Discarding.', data['guild_id']) + def parse_voice_state_update(self, data: gw.VoiceStateUpdateEvent) -> None: guild = self._get_guild(utils._get_as_snowflake(data, 'guild_id')) channel_id = utils._get_as_snowflake(data, 'channel_id') diff --git a/discord/types/audit_log.py b/discord/types/audit_log.py index 53b57d79e..a041a3056 100644 --- a/discord/types/audit_log.py +++ b/discord/types/audit_log.py @@ -25,13 +25,15 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations from typing import List, Literal, Optional, TypedDict, Union + from .webhook import Webhook from .guild import MFALevel, VerificationLevel, ExplicitContentFilterLevel, DefaultMessageNotificationLevel from .integration import IntegrationExpireBehavior, PartialIntegration from .user import User +from .scheduled_event import EntityType, EventStatus, GuildScheduledEvent from .snowflake import Snowflake from .role import Role -from .channel import ChannelType, VideoQualityMode, PermissionOverwrite +from .channel import ChannelType, PrivacyLevel, VideoQualityMode, PermissionOverwrite from .threads import Thread AuditLogEvent = Literal[ @@ -76,6 +78,9 @@ AuditLogEvent = Literal[ 90, 91, 92, + 100, + 101, + 102, 110, 111, 112, @@ -158,6 +163,7 @@ class _AuditLogChange_Int(TypedDict): 'user_limit', 'auto_archive_duration', 'default_auto_archive_duration', + 'communication_disabled_until', ] new_value: int old_value: int @@ -217,6 +223,24 @@ class _AuditLogChange_Overwrites(TypedDict): old_value: List[PermissionOverwrite] +class _AuditLogChange_PrivacyLevel(TypedDict): + key: Literal['privacy_level'] + new_value: PrivacyLevel + old_value: PrivacyLevel + + +class _AuditLogChange_Status(TypedDict): + key: Literal['status'] + new_value: EventStatus + old_value: EventStatus + + +class _AuditLogChange_EntityType(TypedDict): + key: Literal['entity_type'] + new_value: EntityType + old_value: EntityType + + AuditLogChange = Union[ _AuditLogChange_Str, _AuditLogChange_AssetHash, @@ -232,6 +256,9 @@ AuditLogChange = Union[ _AuditLogChange_IntegrationExpireBehaviour, _AuditLogChange_VideoQualityMode, _AuditLogChange_Overwrites, + _AuditLogChange_PrivacyLevel, + _AuditLogChange_Status, + _AuditLogChange_EntityType, ] @@ -265,3 +292,4 @@ class AuditLog(TypedDict): audit_log_entries: List[AuditLogEntry] integrations: List[PartialIntegration] threads: List[Thread] + guild_scheduled_events: List[GuildScheduledEvent] diff --git a/discord/types/channel.py b/discord/types/channel.py index 0e4846e3c..ecea39fa6 100644 --- a/discord/types/channel.py +++ b/discord/types/channel.py @@ -146,7 +146,7 @@ class GroupDMChannel(_BaseChannel): Channel = Union[GuildChannel, DMChannel, GroupDMChannel] -PrivacyLevel = Literal[1, 2] +PrivacyLevel = Literal[2] class StageInstance(TypedDict): diff --git a/discord/types/gateway.py b/discord/types/gateway.py index a8301c924..dc3af8165 100644 --- a/discord/types/gateway.py +++ b/discord/types/gateway.py @@ -29,7 +29,7 @@ from .activity import PartialPresenceUpdate from .voice import GuildVoiceState from .integration import BaseIntegration, IntegrationApplication from .role import Role -from .channel import Channel, ChannelType, StageInstance +from .channel import ChannelType, StageInstance from .interactions import Interaction from .invite import InviteTargetType from .emoji import Emoji, PartialEmoji @@ -41,6 +41,7 @@ from .appinfo import GatewayAppInfo, PartialAppInfo from .guild import Guild, UnavailableGuild from .user import User from .threads import Thread, ThreadMember +from .scheduled_event import GuildScheduledEvent class SessionStartLimit(TypedDict): @@ -348,6 +349,17 @@ class WebhooksUpdateEvent(TypedDict): StageInstanceCreateEvent = StageInstanceUpdateEvent = StageInstanceDeleteEvent = StageInstance +GuildScheduledEventCreateEvent = GuildScheduledEventUpdateEvent = GuildScheduledEventDeleteEvent = GuildScheduledEvent + + +class _GuildScheduledEventUsersEvent(TypedDict): + guild_scheduled_event_id: Snowflake + user_id: Snowflake + guild_id: Snowflake + + +GuildScheduledEventUserAdd = GuildScheduledEventUserRemove = _GuildScheduledEventUsersEvent + VoiceStateUpdateEvent = GuildVoiceState diff --git a/discord/types/guild.py b/discord/types/guild.py index 0d05232ea..a338756ec 100644 --- a/discord/types/guild.py +++ b/discord/types/guild.py @@ -23,8 +23,11 @@ DEALINGS IN THE SOFTWARE. """ from typing import List, Literal, Optional, TypedDict + +from .scheduled_event import GuildScheduledEvent +from .sticker import GuildSticker from .snowflake import Snowflake -from .channel import GuildChannel +from .channel import GuildChannel, StageInstance from .voice import GuildVoiceState from .welcome_screen import WelcomeScreen from .activity import PartialPresenceUpdate @@ -142,6 +145,9 @@ class Guild(_BaseGuildPreview, _GuildOptional): premium_tier: PremiumTier preferred_locale: str public_updates_channel_id: Optional[Snowflake] + stickers: List[GuildSticker] + stage_instances: List[StageInstance] + guild_scheduled_events: List[GuildScheduledEvent] class InviteGuild(Guild, total=False): diff --git a/discord/types/invite.py b/discord/types/invite.py index 0934170a0..6d7818e81 100644 --- a/discord/types/invite.py +++ b/discord/types/invite.py @@ -26,6 +26,8 @@ from __future__ import annotations from typing import Literal, Optional, TypedDict, Union + +from .scheduled_event import GuildScheduledEvent from .snowflake import Snowflake from .guild import InviteGuild, _GuildPreviewUnique from .channel import PartialChannel @@ -41,6 +43,7 @@ class _InviteOptional(TypedDict, total=False): target_user: PartialUser target_type: InviteTargetType target_application: PartialAppInfo + guild_scheduled_event: GuildScheduledEvent class _InviteMetadata(TypedDict, total=False): diff --git a/discord/types/scheduled_event.py b/discord/types/scheduled_event.py index f9cffa8db..0160d1959 100644 --- a/discord/types/scheduled_event.py +++ b/discord/types/scheduled_event.py @@ -22,28 +22,31 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -from typing import Literal, Optional, TypedDict, Union +from typing import List, Literal, Optional, TypedDict, Union from .snowflake import Snowflake from .user import User from .member import Member +from .channel import PrivacyLevel as PrivacyLevel -GuildScheduledEventPrivacyLevel = Literal[1] EventStatus = Literal[1, 2, 3, 4] +EntityType = Literal[1, 2, 3] class _BaseGuildScheduledEventOptional(TypedDict, total=False): creator_id: Optional[Snowflake] description: str creator: User + user_count: int class _BaseGuildScheduledEvent(_BaseGuildScheduledEventOptional): id: Snowflake guild_id: Snowflake + entity_id: Optional[Snowflake] name: str scheduled_start_time: str - privacy_level: GuildScheduledEventPrivacyLevel + privacy_level: PrivacyLevel status: EventStatus image: Optional[str] @@ -80,7 +83,7 @@ GuildScheduledEvent = Union[StageInstanceScheduledEvent, VoiceScheduledEvent, Ex class _WithUserCount(TypedDict): - user_count: str + user_count: int class _StageInstanceScheduledEventWithUserCount(StageInstanceScheduledEvent, _WithUserCount): @@ -107,3 +110,7 @@ class ScheduledEventUser(TypedDict): class ScheduledEventUserWithMember(ScheduledEventUser): member: Member + + +ScheduledEventUsers = List[ScheduledEventUser] +ScheduledEventUsersWithMember = List[ScheduledEventUserWithMember] diff --git a/discord/utils.py b/discord/utils.py index 298ff597f..67c723343 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -40,6 +40,7 @@ from typing import ( List, Literal, Mapping, + NamedTuple, Optional, Protocol, Sequence, @@ -63,6 +64,8 @@ import sys import types import warnings +import yarl + try: import orjson except ModuleNotFoundError: @@ -729,10 +732,19 @@ def _string_width(string: str, *, _IS_ASCII=_IS_ASCII) -> int: return sum(2 if func(char) in UNICODE_WIDE_CHAR_TYPE else 1 for char in string) -def resolve_invite(invite: Union[Invite, str]) -> str: +class ResolvedInvite(NamedTuple): + code: str + event: Optional[int] + + +def resolve_invite(invite: Union[Invite, str]) -> ResolvedInvite: """ Resolves an invite from a :class:`~discord.Invite`, URL or code. + .. versionchanged:: 2.0 + Now returns a :class:`.ResolvedInvite` instead of a + :class:`str`. + Parameters ----------- invite: Union[:class:`~discord.Invite`, :class:`str`] @@ -740,19 +752,24 @@ def resolve_invite(invite: Union[Invite, str]) -> str: Returns -------- - :class:`str` - The invite code. + :class:`.ResolvedInvite` + A data class containing the invite code and the event ID. """ from .invite import Invite # circular import if isinstance(invite, Invite): - return invite.code + return ResolvedInvite(invite.code, invite.scheduled_event_id) else: - rx = r'(?:https?\:\/\/)?discord(?:\.gg|(?:app)?\.com\/invite)\/(.+)' + rx = r'(?:https?\:\/\/)?discord(?:\.gg|(?:app)?\.com\/invite)\/[^/]+' m = re.match(rx, invite) + if m: - return m.group(1) - return invite + url = yarl.URL(invite) + code = url.parts[-1] + event_id = url.query.get('event') + + return ResolvedInvite(code, int(event_id) if event_id else None) + return ResolvedInvite(invite, None) def resolve_template(code: Union[Template, str]) -> str: diff --git a/discord/widget.py b/discord/widget.py index 6bc6a3e49..f64f8da41 100644 --- a/discord/widget.py +++ b/discord/widget.py @@ -314,6 +314,6 @@ class Widget: :class:`Invite` The invite from the widget's invite URL. """ - invite_id = resolve_invite(self._invite) - data = await self._state.http.get_invite(invite_id, with_counts=with_counts) + resolved = resolve_invite(self._invite) + data = await self._state.http.get_invite(resolved.code, with_counts=with_counts) return Invite.from_incomplete(state=self._state, data=data) diff --git a/docs/api.rst b/docs/api.rst index b8ebbdf2b..b864e4480 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1020,6 +1020,53 @@ to handle it, which defaults to print a traceback and ignoring the exception. :param after: The stage instance after the update. :type after: :class:`StageInstance` +.. function:: on_scheduled_event_create(event) + on_scheduled_event_delete(event) + + Called when a :class:`ScheduledEvent` is created or deleted. + + This requires :attr:`Intents.guild_scheduled_events` to be enabled. + + .. versionadded:: 2.0 + + :param event: The scheduled event that was created or deleted. + :type event: :class:`ScheduledEvent` + +.. function:: on_scheduled_event_update(before, after) + + Called when a :class:`ScheduledEvent` is updated. + + This requires :attr:`Intents.guild_scheduled_events` to be enabled. + + The following, but not limited to, examples illustrate when this event is called: + + - The scheduled start/end times are changed. + - The channel is changed. + - The description is changed. + - The status is changed. + - The image is changed. + + .. versionadded:: 2.0 + + :param before: The scheduled event before the update. + :type before: :class:`ScheduledEvent` + :param after: The scheduled event after the update. + :type after: :class:`ScheduledEvent` + +.. function:: on_scheduled_event_user_add(event, user) + on_scheduled_event_user_remove(event, user) + + Called when a user is added or removed from a :class:`ScheduledEvent`. + + This requires :attr:`Intents.guild_scheduled_events` to be enabled. + + .. versionadded:: 2.0 + + :param event: The scheduled event that the user was added or removed from. + :type event: :class:`ScheduledEvent` + :param user: The user that was added or removed. + :type user: :class:`User` + .. function:: on_member_ban(guild, user) Called when user gets banned from a :class:`Guild`. @@ -1110,6 +1157,22 @@ Utility Functions .. autofunction:: discord.utils.escape_mentions +.. class:: ResolvedInvite + + A data class which represents a resolved invite returned from :func:`discord.utils.resolve_invite`. + + .. attribute:: code + + The invite code. + + :type: :class:`str` + + .. attribute:: event + + The id of the scheduled event that the invite refers to. + + :type: Optional[:class:`int`] + .. autofunction:: discord.utils.resolve_invite .. autofunction:: discord.utils.resolve_template @@ -2503,20 +2566,12 @@ of :class:`enum.Enum`. Represents full camera video quality. -.. class:: StagePrivacyLevel +.. class:: PrivacyLevel - Represents a stage instance's privacy level. + Represents the privacy level of a stage instance or scheduled event. .. versionadded:: 2.0 - .. attribute:: public - - The stage instance can be joined by external users. - - .. attribute:: closed - - The stage instance can only be joined by members of the guild. - .. attribute:: guild_only Alias for :attr:`.closed` @@ -2726,6 +2781,50 @@ of :class:`enum.Enum`. The guild requires 2 factor authentication. +.. class:: EntityType + + Represents the type of entity that a scheduled event is for. + + .. versionadded:: 2.0 + + .. attribute:: stage_instance + + The scheduled event will occur in a stage instance. + + .. attribute:: voice + + The scheduled event will occur in a voice channel. + + .. attribute:: external + + The scheduled event will occur externally. + +.. class:: EventStatus + + Represents the status of an event. + + .. versionadded:: 2.0 + + .. attribute:: scheduled + + The event is scheduled. + + .. attribute:: active + + The event is active. + + .. attribute:: completed + + The event has ended. + + .. attribute:: cancelled + + The event has been cancelled. + + .. attribute:: canceled + + An alias for :attr:`cancelled`. + .. _discord-api-audit-logs: Audit Log Data @@ -3007,9 +3106,9 @@ AuditLogDiff .. attribute:: privacy_level - The privacy level of the stage instance. + The privacy level of the stage instance or scheduled event - :type: :class:`StagePrivacyLevel` + :type: :class:`PrivacyLevel` .. attribute:: roles @@ -3207,9 +3306,10 @@ AuditLogDiff .. attribute:: description - The description of a guild, or a sticker. + The description of a guild, a sticker, or a scheduled event. - See also :attr:`Guild.description`, or :attr:`GuildSticker.description`. + See also :attr:`Guild.description`, :attr:`GuildSticker.description`, or + :attr:`ScheduledEvent.description`. :type: :class:`str` @@ -3563,6 +3663,15 @@ Guild :type: :class:`User` +ScheduledEvent +~~~~~~~~~~~~~~ + +.. attributetable:: ScheduledEvent + +.. autoclass:: ScheduledEvent() + :members: + + Integration ~~~~~~~~~~~~