diff --git a/changelog/906.feature.rst b/changelog/906.feature.rst new file mode 100644 index 0000000000..8aa10dbf4f --- /dev/null +++ b/changelog/906.feature.rst @@ -0,0 +1,4 @@ +Add application role connection features. +- Add :class:`ApplicationRoleConnectionMetadata` and :class:`ApplicationRoleConnectionMetadataType` types. +- Add :class:`Client.fetch_role_connection_metadata` and :class:`Client.edit_role_connection_metadata` methods. +- Add :attr:`RoleTags.is_linked_role` and :attr:`AppInfo.role_connections_verification_url` attributes. diff --git a/disnake/__init__.py b/disnake/__init__.py index 3c41f1c09c..154c7d8cda 100644 --- a/disnake/__init__.py +++ b/disnake/__init__.py @@ -26,6 +26,7 @@ from .activity import * from .app_commands import * from .appinfo import * +from .application_role_connection import * from .asset import * from .audit_logs import * from .automod import * diff --git a/disnake/appinfo.py b/disnake/appinfo.py index 092c6e817f..2cc80b1956 100644 --- a/disnake/appinfo.py +++ b/disnake/appinfo.py @@ -145,6 +145,12 @@ class AppInfo: The custom installation url for this application. .. versionadded:: 2.5 + role_connections_verification_url: Optional[:class:`str`] + The application's role connection verification entry point, + which when configured will render the app as a verification method + in the guild role verification configuration. + + .. versionadded:: 2.8 """ __slots__ = ( @@ -170,6 +176,7 @@ class AppInfo: "tags", "install_params", "custom_install_url", + "role_connections_verification_url", ) def __init__(self, state: ConnectionState, data: AppInfoPayload) -> None: @@ -208,6 +215,9 @@ def __init__(self, state: ConnectionState, data: AppInfoPayload) -> None: InstallParams(data["install_params"], parent=self) if "install_params" in data else None ) self.custom_install_url: Optional[str] = data.get("custom_install_url") + self.role_connections_verification_url: Optional[str] = data.get( + "role_connections_verification_url" + ) def __repr__(self) -> str: return ( diff --git a/disnake/application_role_connection.py b/disnake/application_role_connection.py new file mode 100644 index 0000000000..df76bb9dd5 --- /dev/null +++ b/disnake/application_role_connection.py @@ -0,0 +1,112 @@ +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .enums import ApplicationRoleConnectionMetadataType, enum_if_int, try_enum +from .i18n import LocalizationValue, Localized + +if TYPE_CHECKING: + from typing_extensions import Self + + from .i18n import LocalizationProtocol, LocalizedRequired + from .types.application_role_connection import ( + ApplicationRoleConnectionMetadata as ApplicationRoleConnectionMetadataPayload, + ) + + +__all__ = ("ApplicationRoleConnectionMetadata",) + + +class ApplicationRoleConnectionMetadata: + """Represents the role connection metadata of an application. + + See the :ddocs:`API documentation ` + for further details and limits. + + The list of metadata records associated with the current application/bot + can be retrieved/edited using :meth:`Client.fetch_role_connection_metadata` + and :meth:`Client.edit_role_connection_metadata`. + + .. versionadded:: 2.8 + + Attributes + ---------- + type: :class:`ApplicationRoleConnectionMetadataType` + The type of the metadata value. + key: :class:`str` + The dictionary key for the metadata field. + name: :class:`str` + The name of the metadata field. + name_localizations: :class:`LocalizationValue` + The localizations for :attr:`name`. + description: :class:`str` + The description of the metadata field. + description_localizations: :class:`LocalizationValue` + The localizations for :attr:`description`. + """ + + __slots__ = ( + "type", + "key", + "name", + "name_localizations", + "description", + "description_localizations", + ) + + def __init__( + self, + *, + type: ApplicationRoleConnectionMetadataType, + key: str, + name: LocalizedRequired, + description: LocalizedRequired, + ) -> None: + self.type: ApplicationRoleConnectionMetadataType = enum_if_int( + ApplicationRoleConnectionMetadataType, type + ) + self.key: str = key + + name_loc = Localized._cast(name, True) + self.name: str = name_loc.string + self.name_localizations: LocalizationValue = name_loc.localizations + + desc_loc = Localized._cast(description, True) + self.description: str = desc_loc.string + self.description_localizations: LocalizationValue = desc_loc.localizations + + def __repr__(self) -> str: + return ( + f"" + ) + + @classmethod + def _from_data(cls, data: ApplicationRoleConnectionMetadataPayload) -> Self: + return cls( + type=try_enum(ApplicationRoleConnectionMetadataType, data["type"]), + key=data["key"], + name=Localized(data["name"], data=data.get("name_localizations")), + description=Localized(data["description"], data=data.get("description_localizations")), + ) + + def to_dict(self) -> ApplicationRoleConnectionMetadataPayload: + data: ApplicationRoleConnectionMetadataPayload = { + "type": self.type.value, + "key": self.key, + "name": self.name, + "description": self.description, + } + + if (loc := self.name_localizations.data) is not None: + data["name_localizations"] = loc + if (loc := self.description_localizations.data) is not None: + data["description_localizations"] = loc + + return data + + def _localize(self, store: LocalizationProtocol) -> None: + self.name_localizations._link(store) + self.description_localizations._link(store) diff --git a/disnake/client.py b/disnake/client.py index ac68af81b8..8f35516dd0 100644 --- a/disnake/client.py +++ b/disnake/client.py @@ -39,6 +39,7 @@ GuildApplicationCommandPermissions, ) from .appinfo import AppInfo +from .application_role_connection import ApplicationRoleConnectionMetadata from .backoff import ExponentialBackoff from .channel import PartialMessageable, _threaded_channel_factory from .emoji import Emoji @@ -81,6 +82,9 @@ from .channel import DMChannel from .member import Member from .message import Message + from .types.application_role_connection import ( + ApplicationRoleConnectionMetadata as ApplicationRoleConnectionMetadataPayload, + ) from .types.gateway import SessionStartLimit as SessionStartLimitPayload from .voice_client import VoiceProtocol @@ -2710,3 +2714,64 @@ async def fetch_command_permissions( The permissions configured for the specified application command. """ return await self._connection.fetch_command_permissions(guild_id, command_id) + + async def fetch_role_connection_metadata(self) -> List[ApplicationRoleConnectionMetadata]: + """|coro| + + Retrieves the :class:`.ApplicationRoleConnectionMetadata` records for the application. + + .. versionadded:: 2.8 + + Raises + ------ + HTTPException + Retrieving the metadata records failed. + + Returns + ------- + List[:class:`.ApplicationRoleConnectionMetadata`] + The list of metadata records. + """ + data = await self.http.get_application_role_connection_metadata_records(self.application_id) + return [ApplicationRoleConnectionMetadata._from_data(record) for record in data] + + async def edit_role_connection_metadata( + self, records: Sequence[ApplicationRoleConnectionMetadata] + ) -> List[ApplicationRoleConnectionMetadata]: + """|coro| + + Edits the :class:`.ApplicationRoleConnectionMetadata` records for the application. + + An application can have up to 5 metadata records. + + .. warning:: + This will overwrite all existing metadata records. + Consider :meth:`fetching ` them first, + and constructing the new list of metadata records based off of the returned list. + + .. versionadded:: 2.8 + + Parameters + ---------- + records: Sequence[:class:`.ApplicationRoleConnectionMetadata`] + The new metadata records. + + Raises + ------ + HTTPException + Editing the metadata records failed. + + Returns + ------- + List[:class:`.ApplicationRoleConnectionMetadata`] + The list of newly edited metadata records. + """ + payload: List[ApplicationRoleConnectionMetadataPayload] = [] + for record in records: + record._localize(self.i18n) + payload.append(record.to_dict()) + + data = await self.http.edit_application_role_connection_metadata_records( + self.application_id, payload + ) + return [ApplicationRoleConnectionMetadata._from_data(record) for record in data] diff --git a/disnake/enums.py b/disnake/enums.py index 790d59870f..0f951d421f 100644 --- a/disnake/enums.py +++ b/disnake/enums.py @@ -59,6 +59,7 @@ "AutoModActionType", "ThreadSortOrder", "ThreadLayout", + "ApplicationRoleConnectionMetadataType", ) @@ -825,6 +826,17 @@ class ThreadLayout(Enum): gallery_view = 2 +class ApplicationRoleConnectionMetadataType(Enum): + integer_less_than_or_equal = 1 + integer_greater_than_or_equal = 2 + integer_equal = 3 + integer_not_equal = 4 + datetime_less_than_or_equal = 5 + datetime_greater_than_or_equal = 6 + boolean_equal = 7 + boolean_not_equal = 8 + + T = TypeVar("T") diff --git a/disnake/http.py b/disnake/http.py index 5ed636735e..6c69a74670 100644 --- a/disnake/http.py +++ b/disnake/http.py @@ -52,6 +52,7 @@ from .message import Attachment from .types import ( appinfo, + application_role_connection, audit_log, automod, channel, @@ -2622,6 +2623,31 @@ def get_voice_regions(self) -> Response[List[voice.VoiceRegion]]: def application_info(self) -> Response[appinfo.AppInfo]: return self.request(Route("GET", "/oauth2/applications/@me")) + def get_application_role_connection_metadata_records( + self, application_id: Snowflake + ) -> Response[List[application_role_connection.ApplicationRoleConnectionMetadata]]: + return self.request( + Route( + "GET", + "/applications/{application_id}/role-connections/metadata", + application_id=application_id, + ) + ) + + def edit_application_role_connection_metadata_records( + self, + application_id: Snowflake, + records: Sequence[application_role_connection.ApplicationRoleConnectionMetadata], + ) -> Response[List[application_role_connection.ApplicationRoleConnectionMetadata]]: + return self.request( + Route( + "PUT", + "/applications/{application_id}/role-connections/metadata", + application_id=application_id, + ), + json=records, + ) + async def get_gateway(self, *, encoding: str = "json", zlib: bool = True) -> str: try: data: gateway.Gateway = await self.request(Route("GET", "/gateway")) diff --git a/disnake/role.py b/disnake/role.py index 815da1fcdb..f71c0466cd 100644 --- a/disnake/role.py +++ b/disnake/role.py @@ -52,6 +52,7 @@ class RoleTags: "bot_id", "integration_id", "_premium_subscriber", + "_guild_connections", ) def __init__(self, data: RoleTagPayload) -> None: @@ -62,6 +63,7 @@ def __init__(self, data: RoleTagPayload) -> None: # So in this case, a value of None is the same as True. # Which means we would need a different sentinel. self._premium_subscriber: Optional[Any] = data.get("premium_subscriber", MISSING) + self._guild_connections: Optional[Any] = data.get("guild_connections", MISSING) def is_bot_managed(self) -> bool: """Whether the role is associated with a bot. @@ -77,6 +79,15 @@ def is_premium_subscriber(self) -> bool: """ return self._premium_subscriber is None + def is_linked_role(self) -> bool: + """Whether the role is a linked role for the guild. + + .. versionadded:: 2.8 + + :return type: :class:`bool` + """ + return self._guild_connections is None + def is_integration(self) -> bool: """Whether the role is managed by an integration. @@ -87,7 +98,8 @@ def is_integration(self) -> bool: def __repr__(self) -> str: return ( f"" + f"premium_subscriber={self.is_premium_subscriber()} " + f"linked_role={self.is_linked_role()}>" ) @@ -264,6 +276,15 @@ def is_premium_subscriber(self) -> bool: """ return self.tags is not None and self.tags.is_premium_subscriber() + def is_linked_role(self) -> bool: + """Whether the role is a linked role for the guild. + + .. versionadded:: 2.8 + + :return type: :class:`bool` + """ + return self.tags is not None and self.tags.is_linked_role() + def is_integration(self) -> bool: """Whether the role is managed by an integration. diff --git a/disnake/types/appinfo.py b/disnake/types/appinfo.py index 399f62e30a..95138ba158 100644 --- a/disnake/types/appinfo.py +++ b/disnake/types/appinfo.py @@ -41,6 +41,7 @@ class AppInfo(BaseAppInfo): tags: NotRequired[List[str]] install_params: NotRequired[InstallParams] custom_install_url: NotRequired[str] + role_connections_verification_url: NotRequired[str] class PartialAppInfo(BaseAppInfo, total=False): diff --git a/disnake/types/application_role_connection.py b/disnake/types/application_role_connection.py new file mode 100644 index 0000000000..af89e45efb --- /dev/null +++ b/disnake/types/application_role_connection.py @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: MIT + +from typing import Literal, TypedDict + +from typing_extensions import NotRequired + +from .i18n import LocalizationDict + +ApplicationRoleConnectionMetadataType = Literal[1, 2, 3, 4, 5, 6, 7, 8] + + +class ApplicationRoleConnectionMetadata(TypedDict): + type: ApplicationRoleConnectionMetadataType + key: str + name: str + name_localizations: NotRequired[LocalizationDict] + description: str + description_localizations: NotRequired[LocalizationDict] diff --git a/disnake/types/i18n.py b/disnake/types/i18n.py new file mode 100644 index 0000000000..e8aa30eb23 --- /dev/null +++ b/disnake/types/i18n.py @@ -0,0 +1,5 @@ +# SPDX-License-Identifier: MIT + +from typing import Dict + +LocalizationDict = Dict[str, str] diff --git a/disnake/types/interactions.py b/disnake/types/interactions.py index 61d17e93cf..494dc0a37c 100644 --- a/disnake/types/interactions.py +++ b/disnake/types/interactions.py @@ -9,6 +9,7 @@ from .channel import ChannelType from .components import Component, Modal from .embed import Embed +from .i18n import LocalizationDict from .member import Member, MemberWithUser from .role import Role from .snowflake import Snowflake @@ -19,9 +20,6 @@ from .message import AllowedMentions, Attachment, Message -ApplicationCommandLocalizations = Dict[str, str] - - ApplicationCommandType = Literal[1, 2, 3] @@ -31,9 +29,9 @@ class ApplicationCommand(TypedDict): application_id: Snowflake guild_id: NotRequired[Snowflake] name: str - name_localizations: NotRequired[Optional[ApplicationCommandLocalizations]] + name_localizations: NotRequired[Optional[LocalizationDict]] description: str - description_localizations: NotRequired[Optional[ApplicationCommandLocalizations]] + description_localizations: NotRequired[Optional[LocalizationDict]] options: NotRequired[List[ApplicationCommandOption]] default_member_permissions: NotRequired[Optional[str]] dm_permission: NotRequired[Optional[bool]] @@ -48,9 +46,9 @@ class ApplicationCommand(TypedDict): class ApplicationCommandOption(TypedDict): type: ApplicationCommandOptionType name: str - name_localizations: NotRequired[Optional[ApplicationCommandLocalizations]] + name_localizations: NotRequired[Optional[LocalizationDict]] description: str - description_localizations: NotRequired[Optional[ApplicationCommandLocalizations]] + description_localizations: NotRequired[Optional[LocalizationDict]] required: NotRequired[bool] choices: NotRequired[List[ApplicationCommandOptionChoice]] options: NotRequired[List[ApplicationCommandOption]] @@ -67,7 +65,7 @@ class ApplicationCommandOption(TypedDict): class ApplicationCommandOptionChoice(TypedDict): name: str - name_localizations: NotRequired[Optional[ApplicationCommandLocalizations]] + name_localizations: NotRequired[Optional[LocalizationDict]] value: ApplicationCommandOptionChoiceValue @@ -337,9 +335,9 @@ class InteractionMessageReference(TypedDict): class EditApplicationCommand(TypedDict): name: str - name_localizations: NotRequired[Optional[ApplicationCommandLocalizations]] + name_localizations: NotRequired[Optional[LocalizationDict]] description: NotRequired[str] - description_localizations: NotRequired[Optional[ApplicationCommandLocalizations]] + description_localizations: NotRequired[Optional[LocalizationDict]] options: NotRequired[Optional[List[ApplicationCommandOption]]] default_member_permissions: NotRequired[Optional[str]] dm_permission: NotRequired[bool] diff --git a/disnake/types/role.py b/disnake/types/role.py index b416625b82..eee63fb5be 100644 --- a/disnake/types/role.py +++ b/disnake/types/role.py @@ -27,6 +27,7 @@ class RoleTags(TypedDict, total=False): bot_id: Snowflake integration_id: Snowflake premium_subscriber: None + guild_connections: None class CreateRole(TypedDict, total=False): diff --git a/docs/api.rst b/docs/api.rst index 81cbb73b05..0267745ac5 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -3836,6 +3836,39 @@ of :class:`enum.Enum`. Display forum threads in a media-focused collection of tiles. +.. class:: ApplicationRoleConnectionMetadataType + + Represents the type of a role connection metadata value. + + These offer comparison operations which allow guilds to configure role requirements + based on the metadata value for each user and a guild-specified configured value. + + .. versionadded:: 2.8 + + .. attribute:: integer_less_than_or_equal + The metadata value (``integer``) is less than or equal to the guild's configured value. + + .. attribute:: integer_greater_than_or_equal + The metadata value (``integer``) is greater than or equal to the guild's configured value. + + .. attribute:: integer_equal + The metadata value (``integer``) is equal to the guild's configured value. + + .. attribute:: integer_not_equal + The metadata value (``integer``) is not equal to the guild's configured value. + + .. attribute:: datetime_less_than_or_equal + The metadata value (``ISO8601 string``) is less than or equal to the guild's configured value (``integer``; days before current date). + + .. attribute:: datetime_greater_than_or_equal + The metadata value (``ISO8601 string``) is greater than or equal to the guild's configured value (``integer``; days before current date). + + .. attribute:: boolean_equal + The metadata value (``integer``) is equal to the guild's configured value. + + .. attribute:: boolean_not_equal + The metadata value (``integer``) is not equal to the guild's configured value. + Async Iterator ---------------- @@ -5925,6 +5958,14 @@ AutoModTimeoutAction .. autoclass:: AutoModTimeoutAction :members: +ApplicationRoleConnectionMetadata +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: ApplicationRoleConnectionMetadata + +.. autoclass:: ApplicationRoleConnectionMetadata + :members: + File ~~~~~