diff --git a/discord/__init__.py b/discord/__init__.py index 069abf179..93bc013f4 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -34,6 +34,7 @@ from .colour import * from .commands import * from .components import * from .connections import * +from .directory import * from .embeds import * from .emoji import * from .entitlements import * diff --git a/discord/channel.py b/discord/channel.py index 55cce673f..d060b96a2 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -47,7 +47,17 @@ import datetime import discord.abc from .scheduled_event import ScheduledEvent from .permissions import PermissionOverwrite, Permissions -from .enums import ChannelType, EntityType, ForumLayoutType, ForumOrderType, PrivacyLevel, try_enum, VideoQualityMode +from .enums import ( + ChannelType, + EntityType, + ForumLayoutType, + ForumOrderType, + PrivacyLevel, + try_enum, + VideoQualityMode, + DirectoryCategory, + DirectoryEntryType, +) from .calls import PrivateCall, GroupCall from .mixins import Hashable from . import utils @@ -61,15 +71,17 @@ from .flags import ChannelFlags from .http import handle_message_parameters from .invite import Invite from .voice_client import VoiceClient +from .directory import DirectoryEntry __all__ = ( 'TextChannel', 'VoiceChannel', 'StageChannel', - 'DMChannel', 'CategoryChannel', 'ForumTag', 'ForumChannel', + 'DirectoryChannel', + 'DMChannel', 'GroupChannel', 'PartialMessageable', ) @@ -98,6 +110,7 @@ if TYPE_CHECKING: NewsChannel as NewsChannelPayload, VoiceChannel as VoiceChannelPayload, StageChannel as StageChannelPayload, + DirectoryChannel as DirectoryChannelPayload, DMChannel as DMChannelPayload, CategoryChannel as CategoryChannelPayload, GroupDMChannel as GroupChannelPayload, @@ -2093,6 +2106,26 @@ class CategoryChannel(discord.abc.GuildChannel, Hashable): r.sort(key=lambda c: (c.position, c.id)) return r + @property + def directory_channels(self) -> List[DirectoryChannel]: + """List[:class:`DirectoryChannel`]: Returns the directory channels that are under this category. + + .. versionadded:: 2.1 + """ + r = [c for c in self.guild.channels if c.category_id == self.id and isinstance(c, DirectoryChannel)] + r.sort(key=lambda c: (c.position, c.id)) + return r + + @property + def directories(self) -> List[DirectoryChannel]: + """List[:class:`DirectoryChannel`]: Returns the directory channels that are under this category. + + An alias for :attr:`directory_channels`. + + .. versionadded:: 2.1 + """ + return self.directory_channels + async def create_text_channel(self, name: str, **options: Any) -> TextChannel: """|coro| @@ -2131,6 +2164,22 @@ class CategoryChannel(discord.abc.GuildChannel, Hashable): """ return await self.guild.create_stage_channel(name, category=self, **options) + async def create_directory(self, name: str, **options: Any) -> DirectoryChannel: + """|coro| + + A shortcut method to :meth:`Guild.create_directory` to create a :class:`DirectoryChannel` in the category. + + .. versionadded:: 2.1 + + Returns + -------- + :class:`DirectoryChannel` + The channel that was just created. + """ + return await self.guild.create_directory(name, category=self, **options) + + create_directory_channel = create_directory + async def create_forum(self, name: str, **options: Any) -> ForumChannel: """|coro| @@ -2145,6 +2194,8 @@ class CategoryChannel(discord.abc.GuildChannel, Hashable): """ return await self.guild.create_forum(name, category=self, **options) + create_forum_channel = create_forum + class ForumTag(Hashable): """Represents a forum tag that can be applied to a thread within a :class:`ForumChannel`. @@ -2999,6 +3050,372 @@ class ForumChannel(discord.abc.GuildChannel, Hashable): before_timestamp = update_before(threads[-1]) +class DirectoryChannel(discord.abc.GuildChannel, Hashable): + """Represents a directory channel. + + These channels hold entries for guilds attached to a directory (such as a Student Hub). + + .. container:: operations + + .. describe:: x == y + + Checks if two channels are equal. + + .. describe:: x != y + + Checks if two channels are not equal. + + .. describe:: hash(x) + + Returns the channel's hash. + + .. describe:: str(x) + + Returns the channel's name. + + .. versionadded:: 2.1 + + Attributes + ---------- + name: :class:`str` + The channel name. + guild: :class:`Guild` + The guild the channel belongs to. + id: :class:`int` + The channel ID. + category_id: Optional[:class:`int`] + The category channel ID this channel belongs to, if applicable. + topic: Optional[:class:`str`] + The channel's topic. ``None`` if it doesn't exist. + position: :class:`int` + The position in the channel list. This is a number that starts at 0. e.g. the + top channel is position 0. + last_message_id: Optional[:class:`int`] + The last directory entry ID that was created on this channel. It may + *not* point to an existing or valid directory entry. + """ + + __slots__ = ( + 'name', + 'id', + 'guild', + 'topic', + '_state', + 'category_id', + 'position', + '_overwrites', + 'last_message_id', + ) + + def __init__(self, *, state: ConnectionState, guild: Guild, data: DirectoryChannelPayload): + self._state: ConnectionState = state + self.id: int = int(data['id']) + self._update(guild, data) + + def __repr__(self) -> str: + attrs = [ + ('id', self.id), + ('name', self.name), + ('position', self.position), + ('category_id', self.category_id), + ] + joined = ' '.join('%s=%r' % t for t in attrs) + return f'<{self.__class__.__name__} {joined}>' + + def _update(self, guild: Guild, data: DirectoryChannelPayload) -> None: + self.guild: Guild = guild + self.name: str = data['name'] + self.category_id: Optional[int] = utils._get_as_snowflake(data, 'parent_id') + self.topic: Optional[str] = data.get('topic') + self.position: int = data['position'] + self.last_message_id: Optional[int] = utils._get_as_snowflake(data, 'last_message_id') + self._fill_overwrites(data) + + async def _get_channel(self) -> Self: + return self + + @property + def type(self) -> ChannelType: + """:class:`ChannelType`: The channel's Discord type.""" + return ChannelType.directory + + @property + def _sorting_bucket(self) -> int: + return ChannelType.directory.value + + @property + def _scheduled_event_entity_type(self) -> Optional[EntityType]: + return None + + @utils.copy_doc(discord.abc.GuildChannel.permissions_for) + def permissions_for(self, obj: Union[Member, Role], /) -> Permissions: + base = super().permissions_for(obj) + self._apply_implicit_permissions(base) + + # text channels do not have voice related permissions + denied = Permissions.voice() + base.value &= ~denied.value + return base + + @property + def members(self) -> List[Member]: + """List[:class:`Member`]: Returns all members that can see this channel.""" + return [m for m in self.guild.members if self.permissions_for(m).read_messages] + + @property + def read_state(self) -> ReadState: + """:class:`ReadState`: Returns the read state for this channel.""" + return self._state.get_read_state(self.id) + + @property + def acked_message_id(self) -> int: + """:class:`int`: The last directory entry ID that the user has acknowledged. + It may *not* point to an existing or valid directory entry. + """ + return self.read_state.last_acked_id + + @property + def mention_count(self) -> int: + """:class:`int`: Returns how many unread directory entries the user has in this channel.""" + return self.read_state.badge_count + + @property + def last_viewed_timestamp(self) -> datetime.date: + """:class:`datetime.date`: When the channel was last viewed.""" + return self.read_state.last_viewed # type: ignore + + @overload + async def edit(self) -> Optional[DirectoryChannel]: + ... + + @overload + async def edit(self, *, position: int, reason: Optional[str] = ...) -> None: + ... + + @overload + async def edit( + self, + *, + reason: Optional[str] = ..., + name: str = ..., + topic: str = ..., + position: int = ..., + sync_permissions: bool = ..., + category: Optional[CategoryChannel] = ..., + overwrites: Mapping[OverwriteKeyT, PermissionOverwrite] = ..., + ) -> DirectoryChannel: + ... + + async def edit(self, *, reason: Optional[str] = None, **options: Any) -> Optional[DirectoryChannel]: + """|coro| + + Edits the channel. + + You must have :attr:`~Permissions.manage_channels` to do this. + + Parameters + ---------- + name: :class:`str` + The new channel name. + topic: :class:`str` + The new channel's topic. + position: :class:`int` + The new channel's position. + sync_permissions: :class:`bool` + Whether to sync permissions with the channel's new or pre-existing + category. Defaults to ``False``. + category: Optional[:class:`CategoryChannel`] + The new category for this channel. Can be ``None`` to remove the + category. + reason: Optional[:class:`str`] + The reason for editing this channel. Shows up on the audit log. + overwrites: :class:`Mapping` + A :class:`Mapping` of target (either a role or a member) to + :class:`PermissionOverwrite` to apply to the channel. + + Raises + ------ + ValueError + The new ``position`` is less than 0 or greater than the number of channels. + TypeError + The permission overwrite information is not in proper form. + Forbidden + You do not have permissions to edit the channel. + HTTPException + Editing the channel failed. + + Returns + -------- + Optional[:class:`.DirectoryChannel`] + The newly edited directory channel. If the edit was only positional + then ``None`` is returned instead. + """ + payload = await self._edit(options, reason=reason) + if payload is not None: + # the payload will always be the proper channel payload + return self.__class__(state=self._state, guild=self.guild, data=payload) # type: ignore + + @utils.copy_doc(discord.abc.GuildChannel.clone) + async def clone(self, *, name: Optional[str] = None, reason: Optional[str] = None) -> DirectoryChannel: + return await self._clone_impl({'topic': self.topic}, name=name, reason=reason) + + async def counts(self) -> Dict[DirectoryCategory, int]: + """|coro| + + Gets the number of entries in each category. + + Raises + ------- + Forbidden + You don't have permissions to get the counts. + HTTPException + Getting the counts failed. + + Returns + -------- + Dict[:class:`DirectoryCategory`, :class:`int`] + The counts for each category. + """ + data = await self._state.http.get_directory_counts(self.id) + return {try_enum(DirectoryCategory, int(k)): v for k, v in data.items()} + + async def entries( + self, + *, + type: Optional[DirectoryEntryType] = None, + category: Optional[DirectoryCategory] = None, + ) -> List[DirectoryEntry]: + """|coro| + + Gets the directory entries in this channel. + + Raises + ------- + Forbidden + You don't have permissions to get the entries. + HTTPException + Getting the entries failed. + + Returns + -------- + List[:class:`DirectoryEntry`] + The entries in this channel. + """ + state = self._state + data = await state.http.get_directory_entries( + self.id, type=type.value if type else None, category_id=category.value if category else None + ) + return [DirectoryEntry(state=state, data=e, channel=self) for e in data] + + async def fetch_entries(self, *entity_ids: int) -> List[DirectoryEntry]: + r"""|coro| + + Gets a list of partial directory entries by their IDs. + + .. note:: + + These :class:`DirectoryEntry` objects do not have :attr:`DirectoryEntry.guild`. + + Parameters + ----------- + \*entity_ids: :class:`int` + The IDs of the entries to fetch. + + Raises + ------- + Forbidden + You don't have permissions to get the entries. + HTTPException + Getting the entries failed. + + Returns + -------- + List[:class:`DirectoryEntry`] + The entries in this channel. + """ + if not entity_ids: + return [] + + state = self._state + data = await state.http.get_some_directory_entries(self.id, entity_ids) + return [DirectoryEntry(state=state, data=e, channel=self) for e in data] + + async def search_entries( + self, + query: str, + /, + *, + category: Optional[DirectoryCategory] = None, + ) -> List[DirectoryEntry]: + """|coro| + + Searches for directory entries in this channel. + + Parameters + ----------- + query: :class:`str` + The query to search for. + + Raises + ------- + Forbidden + You don't have permissions to search the entries. + HTTPException + Searching the entries failed. + + Returns + -------- + List[:class:`DirectoryEntry`] + The entries in this channel. + """ + state = self._state + data = await state.http.search_directory_entries(self.id, query, category_id=category.value if category else None) + return [DirectoryEntry(state=state, data=e, channel=self) for e in data] + + async def create_entry( + self, + guild: Snowflake, + *, + category: DirectoryCategory = DirectoryCategory.uncategorized, + description: Optional[str] = None, + ) -> DirectoryEntry: + """|coro| + + Creates a directory entry in this channel. + + Parameters + ----------- + guild: :class:`Guild` + The guild to create the entry for. + category: :class:`DirectoryCategory` + The category to create the entry in. + description: Optional[:class:`str`] + The description of the entry. + + Raises + ------- + Forbidden + You don't have permissions to create the entry. + HTTPException + Creating the entry failed. + + Returns + -------- + :class:`DirectoryEntry` + The created entry. + """ + # While the API supports `type`, only guilds seem to be supported at the moment + # So we hide all that from the user and just accept a `guild` + state = self._state + data = await state.http.create_directory_entry( + self.id, + guild.id, + primary_category_id=(category or DirectoryCategory.uncategorized).value, + description=description or '', + ) + return DirectoryEntry(state=state, data=data, channel=self) + + class DMChannel(discord.abc.Messageable, discord.abc.Connectable, discord.abc.PrivateChannel, Hashable): """Represents a Discord direct message channel. @@ -4118,6 +4535,8 @@ def _guild_channel_factory(channel_type: int): return TextChannel, value elif value is ChannelType.stage_voice: return StageChannel, value + elif value is ChannelType.directory: + return DirectoryChannel, value elif value is ChannelType.forum: return ForumChannel, value else: diff --git a/discord/client.py b/discord/client.py index 7264902e9..8b742a7f7 100644 --- a/discord/client.py +++ b/discord/client.py @@ -5064,6 +5064,102 @@ class Client: return experiments + async def join_hub_waitlist(self, email: str, school: str) -> None: + """|coro| + + Signs up for the Discord Student Hub waitlist. + + .. versionadded:: 2.1 + + Parameters + ----------- + email: :class:`str` + The email to sign up with. + school: :class:`str` + The school name to sign up with. + + Raises + ------- + HTTPException + Signing up for the waitlist failed. + """ + await self._connection.http.hub_waitlist_signup(email, school) + + async def lookup_hubs(self, email: str, /) -> List[Guild]: + """|coro| + + Looks up the Discord Student Hubs for the given email. + + .. note:: + + Using this, you will only receive + :attr:`.Guild.id`, :attr:`.Guild.name`, and :attr:`.Guild.icon` per guild. + + .. versionadded:: 2.1 + + Parameters + ----------- + email: :class:`str` + The email to look up. + + Raises + ------- + HTTPException + Looking up the hubs failed. + + Returns + -------- + List[:class:`.Guild`] + The hubs found. + """ + state = self._connection + data = await state.http.hub_lookup(email) + return [Guild(state=state, data=d) for d in data.get('guilds_info', [])] # type: ignore + + @overload + async def join_hub(self, guild: Snowflake, email: str, *, code: None = ...) -> None: + ... + + @overload + async def join_hub(self, guild: Snowflake, email: str, *, code: str = ...) -> Guild: + ... + + async def join_hub(self, guild: Snowflake, email: str, *, code: Optional[str] = None) -> Optional[Guild]: + """|coro| + + Joins the user to or requests a verification code for a Student Hub. + + .. versionadded:: 2.1 + + Parameters + ---------- + guild: :class:`.Guild` + The hub to join. + email: :class:`str` + The email to join with. + code: Optional[:class:`str`] + The email verification code. + + .. note:: + + If not provided, this method requests a verification code instead. + + Raises + ------ + HTTPException + Joining the hub or requesting the verification code failed. + """ + state = self._connection + + if not code: + data = await state.http.hub_lookup(email, guild.id) + if not data.get('has_matching_guild'): + raise ValueError('Guild does not match email') + return + + data = await state.http.join_hub(email, guild.id, code) + return Guild(state=state, data=data['guild']) + async def pomelo_suggestion(self) -> str: """|coro| diff --git a/discord/directory.py b/discord/directory.py new file mode 100644 index 000000000..3d50d0971 --- /dev/null +++ b/discord/directory.py @@ -0,0 +1,192 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Dolfies + +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 TYPE_CHECKING, Optional, Union + +from .enums import DirectoryCategory, DirectoryEntryType, try_enum +from .scheduled_event import ScheduledEvent +from .utils import MISSING, parse_time + +if TYPE_CHECKING: + from datetime import datetime + + from .channel import DirectoryChannel + from .member import Member + from .state import ConnectionState + from .types.directory import ( + DirectoryEntry as DirectoryEntryPayload, + PartialDirectoryEntry as PartialDirectoryEntryPayload, + ) + +__all__ = ('DirectoryEntry',) + + +class DirectoryEntry: + """Represents a directory entry for a channel. + + .. container:: operations + + .. describe:: x == y + + Checks if two entries are equal. + + .. describe:: x != y + + Checks if two entries are not equal. + + .. describe:: hash(x) + + Returns the entry's hash. + + .. versionadded:: 2.1 + + Attributes + ----------- + channel: :class:`DirectoryChannel` + The channel this entry is from. + type: :class:`DirectoryEntryType` + The type of this entry. + category: :class:`DirectoryCategory` + The primary category of this entry. + author_id: :class:`int` + The ID of the user who created this entry. + created_at: :class:`datetime.datetime` + When this entry was created. + description: Optional[:class:`str`] + The description of the entry's guild. + Only applicable for entries of type :attr:`DirectoryEntryType.guild`. + entity_id: :class:`int` + The ID of the entity this entry represents. + guild: Optional[:class:`Guild`] + The guild this entry represents. + For entries of type :attr:`DirectoryEntryType.scheduled_event`, + this is the guild the scheduled event is from. + Not available in all contexts. + featurable: :class:`bool` + Whether this entry's guild can be featured in the directory. + Only applicable for entries of type :attr:`DirectoryEntryType.guild`. + scheduled_event: Optional[:class:`ScheduledEvent`] + The scheduled event this entry represents. + Only applicable for entries of type :attr:`DirectoryEntryType.scheduled_event`. + rsvp: :class:`bool` + Whether the current user has RSVP'd to the scheduled event. + Only applicable for entries of type :attr:`DirectoryEntryType.scheduled_event`. + """ + + def __init__( + self, + *, + data: Union[DirectoryEntryPayload, PartialDirectoryEntryPayload], + state: ConnectionState, + channel: DirectoryChannel, + ): + self.channel = channel + self._state = state + self._update(data) + + def __repr__(self) -> str: + return f'' + + def __hash__(self) -> int: + return hash((self.channel.id, self.entity_id)) + + def __eq__(self, other: object) -> bool: + if isinstance(other, DirectoryEntry): + return self.channel == other.channel and self.entity_id == other.entity_id + return NotImplemented + + def _update(self, data: Union[DirectoryEntryPayload, PartialDirectoryEntryPayload]): + from .guild import Guild + + state = self._state + self.type: DirectoryEntryType = try_enum(DirectoryEntryType, data['type']) + self.category: DirectoryCategory = try_enum(DirectoryCategory, data.get('primary_category_id', 0)) + self.author_id: int = int(data['author_id']) + self.created_at: datetime = parse_time(data['created_at']) + self.description: Optional[str] = data.get('description') or None + self.entity_id: int = int(data['entity_id']) + + guild_data = data.get('guild', data.get('guild_scheduled_event', {}).get('guild')) + self.guild: Optional[Guild] = Guild(data=guild_data, state=state) if guild_data is not None else None + self.featurable: bool = guild_data.get('featurable_in_directory', False) if guild_data is not None else False + + event_data = data.get('guild_scheduled_event') + self.scheduled_event: Optional[ScheduledEvent] = ( + ScheduledEvent(data=event_data, state=state) if event_data is not None else None + ) + self.rsvp: bool = event_data.get('user_rsvp', False) if event_data is not None else False + + @property + def author(self) -> Optional[Member]: + """Optional[:class:`Member`]: The member that created this entry.""" + return self.channel.guild.get_member(self.author_id) + + async def edit(self, *, description: Optional[str] = MISSING, category: DirectoryCategory = MISSING) -> None: + """|coro| + + Edits this directory entry. + Only entries of type :attr:`DirectoryEntryType.guild` can be edited. + + You must be the author of the entry or have + :attr:`~Permissions.manage_guild` in the represented guild to edit it. + + Parameters + ----------- + description: Optional[:class:`str`] + The new description of the entry's guild. + category: :class:`DirectoryCategory` + The new primary category of the entry. + + Raises + ------- + Forbidden + You do not have permissions to edit this entry. + HTTPException + Editing the entry failed. + """ + data = await self._state.http.edit_directory_entry( + self.channel.id, + self.entity_id, + description=description, + primary_category_id=category.value if category is not MISSING else MISSING, + ) + self._update(data) + + async def delete(self) -> None: + """|coro| + + Deletes this directory entry. + + You must be the author of the entry or have + :attr:`~Permissions.manage_guild` in the represented guild to delete it. + + Raises + ------- + Forbidden + You do not have permissions to delete this entry. + HTTPException + Deleting the entry failed. + """ + await self._state.http.delete_directory_entry(self.channel.id, self.entity_id) diff --git a/discord/enums.py b/discord/enums.py index de4f582ba..bc1e107f2 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -25,7 +25,7 @@ from __future__ import annotations import types from collections import namedtuple -from typing import Any, ClassVar, Dict, List, Optional, TYPE_CHECKING, Tuple, Type, TypeVar, Iterator, Mapping +from typing import TYPE_CHECKING, Any, ClassVar, Dict, Iterator, List, Mapping, Optional, Tuple, Type, TypeVar __all__ = ( 'Enum', @@ -119,6 +119,9 @@ __all__ = ( 'ForumLayoutType', 'ForumOrderType', 'ReadStateType', + 'DirectoryEntryType', + 'DirectoryCategory', + 'HubType', ) if TYPE_CHECKING: @@ -253,6 +256,7 @@ class ChannelType(Enum): public_thread = 11 private_thread = 12 stage_voice = 13 + directory = 14 forum = 15 def __str__(self) -> str: @@ -911,6 +915,9 @@ class PrivacyLevel(Enum): closed = 2 guild_only = 2 + def __int__(self) -> int: + return self.value + class ScheduledEventEntityType(Enum): stage_instance = 1 @@ -1566,6 +1573,32 @@ class ReadStateType(Enum): onboarding = 4 +class DirectoryEntryType(Enum): + guild = 0 + scheduled_event = 1 + + def __int__(self) -> int: + return self.value + + +class DirectoryCategory(Enum): + uncategorized = 0 + school_club = 1 + class_subject = 2 + study_social = 3 + miscellaneous = 5 + + def __int__(self) -> int: + return self.value + + +class HubType(Enum): + default = 0 + high_school = 1 + college = 2 + university = 2 + + 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/experiment.py b/discord/experiment.py index ed83474cd..84b887d58 100644 --- a/discord/experiment.py +++ b/discord/experiment.py @@ -26,6 +26,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Dict, Final, Iterator, List, Optional, Sequence, Tuple, Union +from .enums import HubType, try_enum from .metadata import Metadata from .utils import SequenceProxy, SnowflakeList, murmurhash32 @@ -202,13 +203,12 @@ class ExperimentFilters: if ids_filter is not None: return ids_filter.guild_ids - # TODO: Pending hub implementation - # @property - # def hub_types(self) -> Optional[List[HubType]]: - # """Optional[List[:class:`HubType`]]: The hub types that are eligible for the population.""" - # hub_types_filter = self.options.guild_hub_types - # if hub_types_filter is not None: - # return [try_enum(HubType, hub_type) for hub_type in hub_types_filter.guild_hub_types] + @property + def hub_types(self) -> Optional[List[HubType]]: + """Optional[List[:class:`HubType`]]: The Student Hub types that are eligible for the population.""" + hub_types_filter = self.options.guild_hub_types + if hub_types_filter is not None: + return [try_enum(HubType, hub_type) for hub_type in hub_types_filter.guild_hub_types] @property def range_by_hash(self) -> Optional[Tuple[int, int]]: @@ -265,12 +265,11 @@ class ExperimentFilters: if guild.id not in ids: return False - # TODO: Pending hub implementation - # hub_types = self.hub_types - # if hub_types is not None: - # # Guild must be in the list of hub types - # if not guild.hub_type or guild.hub_type not in hub_types: - # return False + hub_types = self.hub_types + if hub_types is not None: + # Guild must be a hub in the list of hub types + if not guild.hub_type or guild.hub_type not in hub_types: + return False range_by_hash = self.range_by_hash if range_by_hash is not None: diff --git a/discord/guild.py b/discord/guild.py index ad9171691..de16159c8 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -63,6 +63,7 @@ from .enums import ( VideoQualityMode, ChannelType, EntityType, + HubType, PrivacyLevel, try_enum, VerificationLevel, @@ -127,6 +128,7 @@ if TYPE_CHECKING: CategoryChannel as CategoryChannelPayload, StageChannel as StageChannelPayload, ForumChannel as ForumChannelPayload, + DirectoryChannel as DirectoryChannelPayload, ) from .types.embed import EmbedType from .types.integration import IntegrationType @@ -139,8 +141,9 @@ if TYPE_CHECKING: from .read_state import ReadState VocalGuildChannel = Union[VoiceChannel, StageChannel] - GuildChannel = Union[VocalGuildChannel, ForumChannel, TextChannel, CategoryChannel] - ByCategoryItem = Tuple[Optional[CategoryChannel], List[GuildChannel]] + NonCategoryChannel = Union[VocalGuildChannel, ForumChannel, TextChannel, DirectoryChannel] + GuildChannel = Union[NonCategoryChannel, CategoryChannel] + ByCategoryItem = Tuple[Optional[CategoryChannel], List[NonCategoryChannel]] MISSING = utils.MISSING @@ -419,6 +422,10 @@ class Guild(Hashable): Indicates if the guild has widget enabled. .. versionadded:: 2.0 + hub_type: Optional[:class:`HubType`] + The type of Student Hub the guild is, if applicable. + + .. versionadded:: 2.1 """ __slots__ = ( @@ -475,6 +482,7 @@ class Guild(Hashable): 'keywords', 'primary_category_id', 'application_command_counts', + 'hub_type', '_joined_at', '_cs_joined', ) @@ -551,7 +559,7 @@ class Guild(Hashable): ('id', self.id), ('name', self.name), ('chunked', self.chunked), - ('member_count', self._member_count), + ('member_count', self.member_count), ) inner = ' '.join('%s=%r' % t for t in attrs) return f'' @@ -621,6 +629,9 @@ class Guild(Hashable): ) self.explicit_content_filter: ContentFilter = try_enum(ContentFilter, guild.get('explicit_content_filter', 0)) self.afk_timeout: int = guild.get('afk_timeout', 0) + self.hub_type: Optional[HubType] = ( + try_enum(HubType, guild.get('hub_type')) if guild.get('hub_type') is not None else None + ) self.unavailable: bool = guild.get('unavailable', False) if self.unavailable: self._member_count = 0 @@ -733,6 +744,13 @@ class Guild(Hashable): def _offline_members_hidden(self) -> bool: return (self._member_count or 0) > 1000 + def is_hub(self) -> bool: + """:class:`bool`: Whether the guild is a Student Hub. + + .. versionadded:: 2.1 + """ + return 'HUB' in self.features + @property def voice_channels(self) -> List[VoiceChannel]: """List[:class:`VoiceChannel`]: A list of voice channels that belongs to this guild. @@ -842,6 +860,28 @@ class Guild(Hashable): r.sort(key=lambda c: (c.position, c.id)) return r + @property + def directory_channels(self) -> List[DirectoryChannel]: + """List[:class:`DirectoryChannel`]: A list of directory channels that belongs to this guild. + + This is sorted by the position and are in UI order from top to bottom. + + .. versionadded:: 2.1 + """ + r = [ch for ch in self._channels.values() if isinstance(ch, DirectoryChannel)] + r.sort(key=lambda c: (c.position, c.id)) + return r + + @property + def directories(self) -> List[DirectoryChannel]: + """List[:class:`DirectoryChannel`]: A list of directory channels that belongs to this guild. + + An alias for :attr:`Guild.directory_channels`. + + .. versionadded:: 2.1 + """ + return self.directory_channels + def by_category(self) -> List[ByCategoryItem]: """Returns every :class:`CategoryChannel` and their associated channels. @@ -855,7 +895,7 @@ class Guild(Hashable): List[Tuple[Optional[:class:`CategoryChannel`], List[:class:`abc.GuildChannel`]]]: The categories and their associated channels. """ - grouped: Dict[Optional[int], List[GuildChannel]] = {} + grouped: Dict[Optional[int], List[NonCategoryChannel]] = {} for channel in self._channels.values(): if isinstance(channel, CategoryChannel): grouped.setdefault(channel.id, []) @@ -866,7 +906,7 @@ class Guild(Hashable): except KeyError: grouped[channel.category_id] = [channel] - def key(t: ByCategoryItem) -> Tuple[Tuple[int, int], List[GuildChannel]]: + def key(t: ByCategoryItem) -> Tuple[Tuple[int, int], List[NonCategoryChannel]]: k, v = t return ((k.position, k.id) if k else (-1, -1), v) @@ -1422,6 +1462,17 @@ class Guild(Hashable): ) -> Coroutine[Any, Any, ForumChannelPayload]: ... + @overload + def _create_channel( + self, + name: str, + channel_type: Literal[ChannelType.directory], + overwrites: Mapping[Union[Role, Member], PermissionOverwrite] = ..., + category: Optional[Snowflake] = ..., + **options: Any, + ) -> Coroutine[Any, Any, DirectoryChannelPayload]: + ... + @overload def _create_channel( self, @@ -1851,6 +1902,83 @@ class Guild(Hashable): create_category_channel = create_category + async def create_directory( + self, + name: str, + *, + reason: Optional[str] = None, + category: Optional[CategoryChannel] = None, + position: int = MISSING, + topic: str = MISSING, + overwrites: Mapping[Union[Role, Member], PermissionOverwrite] = MISSING, + ) -> DirectoryChannel: + """|coro| + + This is similar to :meth:`create_text_channel` except makes a :class:`DirectoryChannel` instead. + + The ``overwrites`` parameter can be used to create a 'secret' + channel upon creation. This parameter expects a :class:`dict` of + overwrites with the target (either a :class:`Member` or a :class:`Role`) + as the key and a :class:`PermissionOverwrite` as the value. + + .. versionadded:: 2.1 + + Parameters + ----------- + name: :class:`str` + The channel's name. + overwrites: Dict[Union[:class:`Role`, :class:`Member`], :class:`PermissionOverwrite`] + A :class:`dict` of target (either a role or a member) to + :class:`PermissionOverwrite` to apply upon creation of a channel. + Useful for creating secret channels. + category: Optional[:class:`CategoryChannel`] + The category to place the newly created channel under. + The permissions will be automatically synced to category if no + overwrites are provided. + position: :class:`int` + The position in the channel list. This is a number that starts + at 0. e.g. the top channel is position 0. + topic: :class:`str` + The new channel's topic. + reason: Optional[:class:`str`] + The reason for creating this channel. Shows up on the audit log. + + Raises + ------- + Forbidden + You do not have the proper permissions to create this channel. + HTTPException + Creating the channel failed. + TypeError + The permission overwrite information is not in proper form. + + Returns + ------- + :class:`TextChannel` + The channel that was just created. + """ + options = {} + if position is not MISSING: + options['position'] = position + if topic is not MISSING: + options['topic'] = topic + + data = await self._create_channel( + name, + overwrites=overwrites, + channel_type=ChannelType.directory, + category=category, + reason=reason, + **options, + ) + channel = DirectoryChannel(state=self._state, guild=self, data=data) + + # temporarily add to the cache + self._channels[channel.id] = channel + return channel + + create_directory_channel = create_directory + async def create_forum( self, name: str, @@ -1990,6 +2118,8 @@ class Guild(Hashable): self._channels[channel.id] = channel return channel + create_forum_channel = create_forum + async def leave(self) -> None: """|coro| @@ -3367,6 +3497,7 @@ class Guild(Hashable): end_time: datetime = ..., description: str = ..., image: bytes = ..., + directory_broadcast: bool = ..., reason: Optional[str] = ..., ) -> ScheduledEvent: ... @@ -3383,6 +3514,7 @@ class Guild(Hashable): end_time: datetime = ..., description: str = ..., image: bytes = ..., + directory_broadcast: bool = ..., reason: Optional[str] = ..., ) -> ScheduledEvent: ... @@ -3398,6 +3530,7 @@ class Guild(Hashable): end_time: datetime = ..., description: str = ..., image: bytes = ..., + directory_broadcast: bool = ..., reason: Optional[str] = ..., ) -> ScheduledEvent: ... @@ -3413,6 +3546,7 @@ class Guild(Hashable): end_time: datetime = ..., description: str = ..., image: bytes = ..., + directory_broadcast: bool = ..., reason: Optional[str] = ..., ) -> ScheduledEvent: ... @@ -3423,12 +3557,13 @@ class Guild(Hashable): name: str, start_time: datetime, entity_type: EntityType = MISSING, - privacy_level: PrivacyLevel = MISSING, + privacy_level: PrivacyLevel = PrivacyLevel.guild_only, channel: Optional[Snowflake] = MISSING, location: str = MISSING, end_time: datetime = MISSING, description: str = MISSING, image: bytes = MISSING, + directory_broadcast: bool = False, reason: Optional[str] = None, ) -> ScheduledEvent: r"""|coro| @@ -3474,6 +3609,11 @@ class Guild(Hashable): The location of the scheduled event. Required if the ``entity_type`` is :attr:`EntityType.external`. + directory_broadcast: :class:`bool` + Whether to broadcast the scheduled event to the directories the guild is in. + You should first check eligibility with :meth:`directory_broadcast_eligibility`. + + .. versionadded:: 2.1 reason: Optional[:class:`str`] The reason for creating this scheduled event. Shows up on the audit log. @@ -3497,17 +3637,23 @@ class Guild(Hashable): :class:`ScheduledEvent` The created scheduled event. """ - payload = {} + payload: Dict[str, Any] = { + 'name': name, + 'privacy_level': int(privacy_level or PrivacyLevel.guild_only.value), + 'broadcast_to_directory_channels': directory_broadcast, + } metadata = {} - payload['name'] = name + 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 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 privacy_level: + if not isinstance(privacy_level, PrivacyLevel): + raise TypeError('privacy_level must be of type PrivacyLevel') + payload['privacy_level'] = (privacy_level or PrivacyLevel.guild_only).value entity_type = entity_type or getattr(channel, '_scheduled_event_entity_type', MISSING) if entity_type is MISSING: @@ -3516,7 +3662,6 @@ class Guild(Hashable): entity_type = EntityType.voice elif channel.type is StageChannel: entity_type = EntityType.stage_instance - elif location not in (MISSING, None): entity_type = EntityType.external else: @@ -3527,15 +3672,9 @@ class Guild(Hashable): if entity_type is None: raise TypeError( - 'invalid GuildChannel type passed, must be VoiceChannel or StageChannel ' f'not {channel.__class__.__name__}' + f'invalid GuildChannel type passed; must be VoiceChannel or StageChannel not {channel.__class__.__name__}' ) - 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 description is not MISSING: payload['description'] = description @@ -3560,10 +3699,10 @@ class Guild(Hashable): metadata['location'] = location - if end_time in (MISSING, None): + if not end_time: raise TypeError('end_time must be set when entity_type is external') - if end_time not in (MISSING, None): + if end_time: 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.' @@ -5135,3 +5274,23 @@ class Guild(Hashable): """ data = await self._state.http.migrate_command_scope(self.id) return list(map(int, data['integration_ids_with_app_commands'])) + + async def directory_broadcast_eligibility(self) -> bool: + """|coro| + + Checks if scheduled events can be broadcasted to the directories the guild is in. + + .. versionadded:: 2.1 + + Raises + ------- + HTTPException + Checking eligibility failed. + + Returns + -------- + :class:`bool` + Whether the guild is eligible to broadcast scheduled events to directories. + """ + data = await self._state.http.get_directory_broadcast_info(self.id, 1) + return data['can_broadcast'] diff --git a/discord/http.py b/discord/http.py index a6942b98e..a65c160ac 100644 --- a/discord/http.py +++ b/discord/http.py @@ -89,10 +89,12 @@ if TYPE_CHECKING: automod, billing, channel, + directory, emoji, entitlements, experiment, guild, + hub, integration, invite, library, @@ -1592,6 +1594,100 @@ class HTTPClient: return self.request(Route('DELETE', '/channels/{channel_id}', channel_id=channel_id), params=params, reason=reason) + def get_directory_entries( + self, + channel_id: Snowflake, + *, + type: Optional[directory.DirectoryEntryType] = None, + category_id: Optional[directory.DirectoryCategory] = None, + ) -> Response[List[directory.DirectoryEntry]]: + params = {} + if type is not None: + params['type'] = type + if category_id is not None: + params['category_id'] = category_id + + return self.request(Route('GET', '/channels/{channel_id}/directory-entries', channel_id=channel_id), params=params) + + def get_some_directory_entries( + self, + channel_id: Snowflake, + entity_ids: Sequence[Snowflake], + ) -> Response[List[directory.PartialDirectoryEntry]]: + params = {'entity_ids': entity_ids} + return self.request( + Route('GET', '/channels/{channel_id}/directory-entries/list', channel_id=channel_id), params=params + ) + + def get_directory_counts(self, channel_id: Snowflake) -> Response[directory.DirectoryCounts]: + return self.request(Route('GET', '/channels/{channel_id}/directory-entries/counts', channel_id=channel_id)) + + def search_directory_entries( + self, + channel_id: Snowflake, + query: str, + *, + type: Optional[directory.DirectoryEntryType] = None, + category_id: Optional[directory.DirectoryCategory] = None, + ) -> Response[List[directory.DirectoryEntry]]: + params: Dict[str, Any] = {'query': query} + if type is not None: + params['type'] = type + if category_id is not None: + params['category_id'] = category_id + + return self.request( + Route('GET', '/channels/{channel_id}/directory-entries/search', channel_id=channel_id), params=params + ) + + def create_directory_entry( + self, + channel_id: Snowflake, + entity_id: Snowflake, + type: directory.DirectoryEntryType = MISSING, + primary_category_id: directory.DirectoryCategory = MISSING, + description: Optional[str] = MISSING, + ) -> Response[directory.DirectoryEntry]: + payload = {} + if type is not MISSING: + payload['type'] = type + if primary_category_id is not MISSING: + payload['primary_category_id'] = primary_category_id + if description is not MISSING: + payload['description'] = description + + return self.request( + Route('POST', '/channels/{channel_id}/directory-entry/{entity_id}', channel_id=channel_id, entity_id=entity_id), + json=payload, + ) + + def edit_directory_entry( + self, + channel_id: Snowflake, + entity_id: Snowflake, + description: Optional[str] = MISSING, + primary_category_id: directory.DirectoryCategory = MISSING, + ) -> Response[directory.DirectoryEntry]: + payload = {} + if description is not MISSING: + payload['description'] = description or '' + if primary_category_id is not MISSING: + payload['primary_category_id'] = primary_category_id + + return self.request( + Route('PATCH', '/channels/{channel_id}/directory-entry/{entity_id}', channel_id=channel_id, entity_id=entity_id), + json=payload, + ) + + def delete_directory_entry(self, channel_id: Snowflake, entity_id: int) -> Response[None]: + return self.request( + Route('DELETE', '/channels/{channel_id}/directory-entry/{entity_id}', channel_id=channel_id, entity_id=entity_id) + ) + + def get_directory_broadcast_info(self, guild_id: Snowflake, type: int) -> Response[directory.DirectoryBroadcast]: + params = {'type': type} + return self.request(Route('GET', '/guilds/{guild_id}/directory-entries/broadcast', guild_id=guild_id), params=params) + # Thread management def start_thread_with_message( @@ -4705,3 +4801,35 @@ class HTTPClient: ) -> Response[Union[experiment.ExperimentResponse, experiment.ExperimentResponseWithGuild]]: params = {'with_guild_experiments': str(with_guild_experiments).lower()} return self.request(Route('GET', '/experiments'), params=params, context_properties=ContextProperties.empty()) + + # Hubs + + def hub_waitlist_signup(self, email: str, school: str) -> Response[hub.HubWaitlist]: + payload = {'email': email, 'school': school} + return self.request(Route('POST', '/hub-waitlist/signup'), json=payload) + + def hub_lookup( + self, + email: str, + guild_id: Optional[Snowflake] = None, + *, + use_verification_code: bool = True, + allow_multiple_guilds: bool = True, + ) -> Response[hub.EmailDomainLookup]: + payload = { + 'email': email, + 'use_verification_code': use_verification_code, + 'allow_multiple_guilds': allow_multiple_guilds, + } + if guild_id is not None: + payload['guild_id'] = guild_id + + return self.request(Route('POST', '/guilds/automations/email-domain-lookup'), json=payload) + + def join_hub(self, email: str, guild_id: Snowflake, code: str) -> Response[hub.EmailDomainVerification]: + payload = {'email': email, 'guild_id': guild_id, 'code': code} + return self.request(Route('POST', '/guilds/automations/email-domain-lookup/verify-code'), json=payload) + + def join_hub_token(self, token: str) -> Response[hub.EmailDomainVerification]: + payload = {'token': token} + return self.request(Route('POST', '/guilds/automations/email-domain-lookup/verify'), json=payload) diff --git a/discord/scheduled_event.py b/discord/scheduled_event.py index 750df9c66..ef2b51e2a 100644 --- a/discord/scheduled_event.py +++ b/discord/scheduled_event.py @@ -321,6 +321,7 @@ class ScheduledEvent(Hashable): privacy_level: PrivacyLevel = ..., status: EventStatus = ..., image: bytes = ..., + directory_broadcast: bool = ..., reason: Optional[str] = ..., ) -> ScheduledEvent: ... @@ -338,6 +339,7 @@ class ScheduledEvent(Hashable): entity_type: Literal[EntityType.voice, EntityType.stage_instance], status: EventStatus = ..., image: bytes = ..., + directory_broadcast: bool = ..., reason: Optional[str] = ..., ) -> ScheduledEvent: ... @@ -355,6 +357,7 @@ class ScheduledEvent(Hashable): status: EventStatus = ..., image: bytes = ..., location: str, + directory_broadcast: bool = ..., reason: Optional[str] = ..., ) -> ScheduledEvent: ... @@ -371,6 +374,7 @@ class ScheduledEvent(Hashable): privacy_level: PrivacyLevel = ..., status: EventStatus = ..., image: bytes = ..., + directory_broadcast: bool = ..., reason: Optional[str] = ..., ) -> ScheduledEvent: ... @@ -387,6 +391,7 @@ class ScheduledEvent(Hashable): status: EventStatus = ..., image: bytes = ..., location: str, + directory_broadcast: bool = ..., reason: Optional[str] = ..., ) -> ScheduledEvent: ... @@ -404,6 +409,7 @@ class ScheduledEvent(Hashable): status: EventStatus = MISSING, image: bytes = MISSING, location: str = MISSING, + directory_broadcast: bool = MISSING, reason: Optional[str] = None, ) -> ScheduledEvent: r"""|coro| @@ -451,6 +457,11 @@ class ScheduledEvent(Hashable): The new location of the scheduled event. Required if the entity type is :attr:`EntityType.external`. + directory_broadcast: :class:`bool` + Whether to broadcast the scheduled event to the directories the guild is in. + You should first check eligibility with :meth:`Guild.directory_broadcast_eligibility`. + + .. versionadded:: 2.1 reason: Optional[:class:`str`] The reason for editing the scheduled event. Shows up on the audit log. @@ -492,7 +503,7 @@ class ScheduledEvent(Hashable): if privacy_level is not MISSING: if not isinstance(privacy_level, PrivacyLevel): - raise TypeError('privacy_level must be of type PrivacyLevel.') + raise TypeError('privacy_level must be of type PrivacyLevel') payload['privacy_level'] = privacy_level.value @@ -523,7 +534,7 @@ class ScheduledEvent(Hashable): if entity_type is None: raise TypeError( - f'invalid GuildChannel type passed, must be VoiceChannel or StageChannel not {channel.__class__.__name__}' + f'invalid GuildChannel type passed; must be VoiceChannel or StageChannel not {channel.__class__.__name__}' ) _entity_type = entity_type or self.entity_type @@ -563,6 +574,9 @@ class ScheduledEvent(Hashable): else: payload['scheduled_end_time'] = end_time + if directory_broadcast is not MISSING: + payload['broadcast_to_directory_channels'] = directory_broadcast + if metadata: payload['entity_metadata'] = metadata @@ -680,37 +694,39 @@ class ScheduledEvent(Hashable): count = 0 for count, user in enumerate(users, 1): + if user.id not in self._users: + self._add_user(user) yield user if count < 100: # There's no data left after this break - async def subscribe(self) -> None: + async def rsvp(self) -> None: """|coro| - Subscribes the current user to this event. + Marks the current user as interested in this event. .. versionadded:: 2.1 Raises ------- HTTPException - Subscribing failed. + RSVPing failed. """ await self._state.http.create_scheduled_event_user(self.guild_id, self.id) - async def unsubscribe(self) -> None: + async def unrsvp(self) -> None: """|coro| - Unsubscribes the current user from this event. + Unmarks the current user as interested in this event. .. versionadded:: 2.1 Raises ------- HTTPException - Unsubscribing failed. + Un-RSVPing failed. """ await self._state.http.delete_scheduled_event_user(self.guild_id, self.id) diff --git a/discord/types/channel.py b/discord/types/channel.py index 9369919b9..638e24753 100644 --- a/discord/types/channel.py +++ b/discord/types/channel.py @@ -40,21 +40,21 @@ class PermissionOverwrite(TypedDict): deny: str -ChannelTypeWithoutThread = Literal[0, 1, 2, 3, 4, 5, 6, 13, 15] +ChannelTypeWithoutThread = Literal[0, 1, 2, 3, 4, 5, 6, 13, 14, 15] ChannelType = Union[ChannelTypeWithoutThread, ThreadType] class _BaseChannel(TypedDict): id: Snowflake - name: str class _BaseGuildChannel(_BaseChannel): guild_id: Snowflake position: int permission_overwrites: List[PermissionOverwrite] - nsfw: bool parent_id: Optional[Snowflake] + name: str + flags: int class PartialRecipient(TypedDict): @@ -62,6 +62,7 @@ class PartialRecipient(TypedDict): class PartialChannel(_BaseChannel): + name: Optional[str] type: ChannelType icon: NotRequired[Optional[str]] recipients: NotRequired[List[PartialRecipient]] @@ -74,6 +75,7 @@ class _BaseTextChannel(_BaseGuildChannel, total=False): rate_limit_per_user: int default_thread_rate_limit_per_user: int default_auto_archive_duration: ThreadArchiveDuration + nsfw: bool class TextChannel(_BaseTextChannel): @@ -105,6 +107,13 @@ class StageChannel(_BaseGuildChannel): user_limit: int rtc_region: NotRequired[Optional[str]] topic: NotRequired[str] + nsfw: bool + + +class DirectoryChannel(_BaseGuildChannel): + type: Literal[14] + last_message_id: Optional[Snowflake] + topic: Optional[str] class ThreadChannel(_BaseChannel): @@ -123,8 +132,8 @@ class ThreadChannel(_BaseChannel): rate_limit_per_user: NotRequired[int] last_message_id: NotRequired[Optional[Snowflake]] last_pin_timestamp: NotRequired[str] - flags: NotRequired[int] applied_tags: NotRequired[List[Snowflake]] + flags: int class DefaultReaction(TypedDict): @@ -150,10 +159,11 @@ class ForumChannel(_BaseTextChannel): default_reaction_emoji: Optional[DefaultReaction] default_sort_order: Optional[ForumOrderType] default_forum_layout: NotRequired[ForumLayoutType] - flags: NotRequired[int] -GuildChannel = Union[TextChannel, NewsChannel, VoiceChannel, CategoryChannel, StageChannel, ThreadChannel, ForumChannel] +GuildChannel = Union[ + TextChannel, NewsChannel, VoiceChannel, CategoryChannel, StageChannel, DirectoryChannel, ThreadChannel, ForumChannel +] class DMChannel(_BaseChannel): @@ -167,6 +177,7 @@ class DMChannel(_BaseChannel): class GroupDMChannel(_BaseChannel): type: Literal[3] + name: Optional[str] icon: Optional[str] owner_id: Snowflake recipients: List[PartialUser] @@ -174,7 +185,7 @@ class GroupDMChannel(_BaseChannel): Channel = Union[GuildChannel, DMChannel, GroupDMChannel] -PrivacyLevel = Literal[2] +PrivacyLevel = Literal[1, 2] class StageInstance(TypedDict): diff --git a/discord/types/directory.py b/discord/types/directory.py new file mode 100644 index 000000000..31df322af --- /dev/null +++ b/discord/types/directory.py @@ -0,0 +1,81 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Dolfies + +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 typing import Dict, Literal, Optional, TypedDict, Union +from typing_extensions import NotRequired + +from .guild import PartialGuild, _GuildCounts +from .scheduled_event import ExternalScheduledEvent, StageInstanceScheduledEvent, VoiceScheduledEvent +from .snowflake import Snowflake + + +class _DirectoryScheduledEvent(TypedDict): + guild: PartialGuild + user_rsvp: bool + user_count: int + + +class _DirectoryStageInstanceScheduledEvent(_DirectoryScheduledEvent, StageInstanceScheduledEvent): + ... + + +class _DirectoryVoiceScheduledEvent(_DirectoryScheduledEvent, VoiceScheduledEvent): + ... + + +class _DirectoryExternalScheduledEvent(_DirectoryScheduledEvent, ExternalScheduledEvent): + ... + + +DirectoryScheduledEvent = Union[ + _DirectoryStageInstanceScheduledEvent, _DirectoryVoiceScheduledEvent, _DirectoryExternalScheduledEvent +] + + +class DirectoryGuild(PartialGuild, _GuildCounts): + featurable_in_directory: bool + + +DirectoryEntryType = Literal[0, 1] +DirectoryCategory = Literal[0, 1, 2, 3, 5] +DirectoryCounts = Dict[DirectoryCategory, int] + + +class PartialDirectoryEntry(TypedDict): + type: DirectoryEntryType + primary_category_id: NotRequired[DirectoryCategory] + directory_channel_id: Snowflake + author_id: Snowflake + entity_id: Snowflake + created_at: str + description: Optional[str] + + +class DirectoryEntry(PartialDirectoryEntry): + guild: NotRequired[DirectoryGuild] + guild_scheduled_event: NotRequired[DirectoryScheduledEvent] + + +class DirectoryBroadcast(TypedDict): + can_broadcast: bool diff --git a/discord/types/guild.py b/discord/types/guild.py index 287b92e1a..528ebcb5e 100644 --- a/discord/types/guild.py +++ b/discord/types/guild.py @@ -66,26 +66,27 @@ class BaseGuild(TypedDict): class PartialGuild(BaseGuild): - name: str - icon: Optional[str] + description: Optional[str] splash: Optional[str] discovery_splash: Optional[str] + home_header: Optional[str] + + +class _GuildMedia(PartialGuild): emojis: List[Emoji] stickers: List[GuildSticker] - features: List[str] - description: Optional[str] -class _GuildPreviewUnique(TypedDict): +class _GuildCounts(TypedDict): approximate_member_count: int approximate_presence_count: int -class GuildPreview(PartialGuild, _GuildPreviewUnique): +class GuildPreview(_GuildMedia, _GuildCounts): ... -class Guild(UnavailableGuild, PartialGuild): +class Guild(UnavailableGuild, _GuildMedia): owner_id: Snowflake region: str afk_channel_id: Optional[Snowflake] @@ -125,6 +126,7 @@ class Guild(UnavailableGuild, PartialGuild): premium_subscription_count: NotRequired[int] max_video_channel_users: NotRequired[int] application_command_counts: ApplicationCommandCounts + hub_type: Optional[Literal[0, 1, 2]] class UserGuild(BaseGuild): @@ -138,7 +140,7 @@ class InviteGuild(Guild, total=False): welcome_screen: WelcomeScreen -class GuildWithCounts(Guild, _GuildPreviewUnique): +class GuildWithCounts(Guild, _GuildCounts): ... diff --git a/discord/types/hub.py b/discord/types/hub.py new file mode 100644 index 000000000..9bc61e28c --- /dev/null +++ b/discord/types/hub.py @@ -0,0 +1,52 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Dolfies + +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 typing import List, Optional, TypedDict +from typing_extensions import NotRequired + +from .guild import Guild +from .snowflake import Snowflake + + +class HubWaitlist(TypedDict): + email: str + email_domain: str + school: str + user_id: Snowflake + + +class HubGuild(TypedDict): + id: Snowflake + name: str + icon: Optional[str] + + +class EmailDomainLookup(TypedDict): + guilds_info: NotRequired[List[HubGuild]] + has_matching_guild: bool + + +class EmailDomainVerification(TypedDict): + guild: Guild + joined: bool diff --git a/discord/types/invite.py b/discord/types/invite.py index 9b8757a1c..d3f49b7c1 100644 --- a/discord/types/invite.py +++ b/discord/types/invite.py @@ -29,7 +29,7 @@ from typing_extensions import NotRequired from .scheduled_event import GuildScheduledEvent from .snowflake import Snowflake -from .guild import InviteGuild, _GuildPreviewUnique +from .guild import InviteGuild, _GuildCounts from .channel import PartialChannel from .user import PartialUser from .application import PartialApplication @@ -65,7 +65,7 @@ class Invite(IncompleteInvite, total=False): guild_scheduled_event: GuildScheduledEvent -class InviteWithCounts(Invite, _GuildPreviewUnique): +class InviteWithCounts(Invite, _GuildCounts): ... diff --git a/discord/types/scheduled_event.py b/discord/types/scheduled_event.py index e1758698e..5dd03db88 100644 --- a/discord/types/scheduled_event.py +++ b/discord/types/scheduled_event.py @@ -47,6 +47,7 @@ class _BaseGuildScheduledEvent(TypedDict): creator: NotRequired[User] user_count: NotRequired[int] image: NotRequired[Optional[str]] + sku_ids: List[Snowflake] class _VoiceChannelScheduledEvent(_BaseGuildScheduledEvent): diff --git a/discord/types/webhook.py b/discord/types/webhook.py index dd5eea156..d2ce56ac3 100644 --- a/discord/types/webhook.py +++ b/discord/types/webhook.py @@ -38,6 +38,11 @@ class SourceGuild(TypedDict): icon: str +class SourceChannel(TypedDict): + id: int + name: str + + WebhookType = Literal[1, 2, 3] diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index f842afbad..a6c98a347 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -75,6 +75,7 @@ if TYPE_CHECKING: from ..types.webhook import ( Webhook as WebhookPayload, SourceGuild as SourceGuildPayload, + SourceChannel as SourceChannelPayload, ) from ..types.message import ( Message as MessagePayload, @@ -83,9 +84,6 @@ if TYPE_CHECKING: User as UserPayload, PartialUser as PartialUserPayload, ) - from ..types.channel import ( - PartialChannel as PartialChannelPayload, - ) from ..types.emoji import PartialEmoji as PartialEmojiPayload BE = TypeVar('BE', bound=BaseException) @@ -443,7 +441,7 @@ class PartialWebhookChannel(Hashable): __slots__ = ('id', 'name') - def __init__(self, *, data: PartialChannelPayload) -> None: + def __init__(self, *, data: SourceChannelPayload) -> None: self.id: int = int(data['id']) self.name: str = data['name'] diff --git a/docs/api.rst b/docs/api.rst index 57066bb60..e55cbf023 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1691,22 +1691,28 @@ of :class:`enum.Enum`. .. attribute:: news_thread - A news thread + A news thread. .. versionadded:: 2.0 .. attribute:: public_thread - A public thread + A public thread. .. versionadded:: 2.0 .. attribute:: private_thread - A private thread + A private thread. .. versionadded:: 2.0 + .. attribute:: directory + + A directory channel. + + .. versionadded:: 2.1 + .. attribute:: forum A forum channel. @@ -3559,7 +3565,7 @@ of :class:`enum.Enum`. .. attribute:: canceled - Alias for :attr:`cancelled`. + An alias for :attr:`cancelled`. .. attribute:: deferred @@ -4185,7 +4191,7 @@ of :class:`enum.Enum`. .. attribute:: canceled - Alias for :attr:`PaymentStatus.cancelled`. + An alias for :attr:`PaymentStatus.cancelled`. .. class:: EntitlementType @@ -5730,6 +5736,67 @@ of :class:`enum.Enum`. Represents a guild-bound read state for guild onboarding. Only one exists per guild. +.. class:: DirectoryEntryType + + Represents the type of a directory entry. + + .. versionadded:: 2.1 + + .. attribute:: guild + + Represents a guild directory entry. + + .. attribute:: scheduled_event + + Represents a broadcasted scheduled event directory entry. + +.. class:: DirectoryCategory + + Represents the category of a directory entry. + + .. versionadded:: 2.1 + + .. attribute:: uncategorized + + The directory entry is uncategorized. + + .. attribute:: school_club + + The directory entry is a school club. + + .. attribute:: class_subject + + The directory entry is a class/subject. + + .. attribute:: study_social + + The directory entry is a study/social venue. + + .. attribute:: miscellaneous + + The directory entry is miscellaneous. + +.. class:: HubType + + Represents the type of Student Hub a guild is. + + .. versionadded:: 2.1 + + .. attribute:: default + + The Student Hub is not categorized as a high school or post-secondary institution. + + .. attribute:: high_school + + The Student Hub is for a high school. + + .. attribute:: college + + The Student Hub is for a post-secondary institution (college or university). + + .. attribute:: university + + An alias for :attr:`college`. .. _discord-api-audit-logs: @@ -7345,6 +7412,12 @@ GuildChannel :members: :inherited-members: +.. attributetable:: DirectoryChannel + +.. autoclass:: DirectoryChannel() + :members: + :inherited-members: + .. attributetable:: ForumChannel .. autoclass:: ForumChannel() @@ -7832,12 +7905,20 @@ Permissions .. autoclass:: PermissionOverwrite() :members: +DirectoryEntry +~~~~~~~~~~~~~~~ + +.. attributetable:: DirectoryEntry + +.. autoclass:: DirectoryEntry() + :members: + ForumTag ~~~~~~~~~ .. attributetable:: ForumTag -.. autoclass:: ForumTag +.. autoclass:: ForumTag() :members: Experiment