diff --git a/CHANGELOG.md b/CHANGELOG.md index c84e2b6b50..7c84278116 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,8 @@ These changes are available on the `master` branch, but have not yet been releas ([#2131](https://github.com/Pycord-Development/pycord/pull/2131)) - Added support for guild onboarding related features. ([#2127](https://github.com/Pycord-Development/pycord/pull/2127)) +- Added support for monetization-related objects and events. + ([#2273](https://github.com/Pycord-Development/pycord/pull/2273)) ### Changed diff --git a/discord/__init__.py b/discord/__init__.py index c49ae8902c..5564eebe77 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -52,6 +52,7 @@ from .member import * from .mentions import * from .message import * +from .monetization import * from .object import * from .onboarding import * from .partial_emoji import * diff --git a/discord/client.py b/discord/client.py index 3144011967..416a0a77c4 100644 --- a/discord/client.py +++ b/discord/client.py @@ -51,6 +51,7 @@ from .invite import Invite from .iterators import GuildIterator from .mentions import AllowedMentions +from .monetization import SKU, Entitlement from .object import Object from .stage_instance import StageInstance from .state import ConnectionState @@ -2002,3 +2003,33 @@ async def update_role_connection_metadata_records( self.application_id, payload ) return [ApplicationRoleConnectionMetadata.from_dict(r) for r in data] + + async def fetch_skus(self) -> list[SKU]: + """|coro| + + Fetches the bot's SKUs. + + .. versionadded:: 2.5 + + Returns + ------- + List[:class:`.SKU`] + The bot's SKUs. + """ + data = await self._connection.http.list_skus(self.application_id) + return [SKU(data=s) for s in data] + + async def fetch_entitlements(self) -> list[Entitlement]: + """|coro| + + Fetches the bot's entitlements. + + .. versionadded:: 2.5 + + Returns + ------- + List[:class:`.Entitlement`] + The bot's entitlements. + """ + data = await self._connection.http.list_entitlements(self.application_id) + return [Entitlement(data=e, state=self._connection) for e in data] diff --git a/discord/enums.py b/discord/enums.py index 6583b2bf5e..2efe57019e 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -70,6 +70,9 @@ "PromptType", "OnboardingMode", "ReactionType", + "SKUType", + "EntitlementType", + "EntitlementOwnerType", ) @@ -674,6 +677,7 @@ class InteractionResponseType(Enum): message_update = 7 # for components auto_complete_result = 8 # for autocomplete interactions modal = 9 # for modal dialogs + premium_required = 10 class VideoQualityMode(Enum): @@ -985,6 +989,26 @@ class ReactionType(Enum): burst = 1 +class SKUType(Enum): + """The SKU type""" + + subscription = 5 + subscription_group = 6 + + +class EntitlementType(Enum): + """The entitlement type""" + + application_subscription = 8 + + +class EntitlementOwnerType(Enum): + """The entitlement owner type""" + + guild = 1 + user = 2 + + T = TypeVar("T") diff --git a/discord/flags.py b/discord/flags.py index 2238a1492d..832f364b6c 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -38,6 +38,7 @@ "MemberCacheFlags", "ApplicationFlags", "ChannelFlags", + "SKUFlags", ) FV = TypeVar("FV", bound="flag_value") @@ -1492,8 +1493,6 @@ def require_tag(self): class AttachmentFlags(BaseFlags): r"""Wraps up the Discord Attachment flags. - See :class:`SystemChannelFlags`. - .. container:: operations .. describe:: x == y @@ -1519,20 +1518,20 @@ class AttachmentFlags(BaseFlags): Returns the inverse of a flag. .. describe:: hash(x) - Return the flag's hash. + Return the flag's hash. .. describe:: iter(x) - Returns an iterator of ``(name, value)`` pairs. This allows it - to be, for example, constructed as a dict or a list of pairs. + Returns an iterator of ``(name, value)`` pairs. This allows it + to be, for example, constructed as a dict or a list of pairs. + Note that aliases are not shown. .. versionadded:: 2.5 Attributes ----------- value: :class:`int` - The raw value. This value is a bit array field of a 53-bit integer - representing the currently available flags. You should query - flags via the properties rather than using this raw value. + The raw value. You should query flags via the properties + rather than using this raw value. """ __slots__ = () @@ -1551,3 +1550,66 @@ def is_thumbnail(self): def is_remix(self): """:class:`bool`: Returns ``True`` if the attachment has been remixed.""" return 1 << 2 + + +@fill_with_flags() +class SKUFlags(BaseFlags): + r"""Wraps up the Discord SKU flags. + + .. container:: operations + + .. describe:: x == y + + Checks if two SKUFlags are equal. + .. describe:: x != y + + Checks if two SKUFlags are not equal. + .. describe:: x + y + + Adds two flags together. Equivalent to ``x | y``. + .. describe:: x - y + + Subtracts two flags from each other. + .. describe:: x | y + + Returns the union of two flags. Equivalent to ``x + y``. + .. describe:: x & y + + Returns the intersection of two flags. + .. describe:: ~x + + Returns the inverse of a flag. + .. describe:: hash(x) + + Return the flag's hash. + .. describe:: iter(x) + + Returns an iterator of ``(name, value)`` pairs. This allows it + to be, for example, constructed as a dict or a list of pairs. + Note that aliases are not shown. + + .. versionadded:: 2.5 + + Attributes + ----------- + value: :class:`int` + The raw value. You should query flags via the properties + rather than using this raw value. + """ + + __slots__ = () + + @flag_value + def available(self): + """:class:`bool`: Returns ``True`` if the SKU is available for purchase.""" + return 1 << 2 + + @flag_value + def guild_subscription(self): + """:class:`bool`: Returns ``True`` if the SKU is a guild subscription.""" + return 1 << 7 + + @flag_value + def user_subscription(self): + """:class:`bool`: Returns ``True`` if the SKU is a user subscription.""" + return 1 << 8 diff --git a/discord/guild.py b/discord/guild.py index 2e3da13d34..f4722c566a 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -54,6 +54,7 @@ AutoModTriggerType, ChannelType, ContentFilter, + EntitlementOwnerType, NotificationLevel, NSFWLevel, ScheduledEventLocationType, @@ -71,6 +72,7 @@ from .iterators import AuditLogIterator, BanIterator, MemberIterator from .member import Member, VoiceState from .mixins import Hashable +from .monetization import Entitlement from .onboarding import Onboarding from .permissions import PermissionOverwrite from .role import Role @@ -3950,3 +3952,26 @@ async def delete_auto_moderation_rule( """ await self._state.http.delete_auto_moderation_rule(self.id, id, reason=reason) + + async def create_test_entitlement(self, sku: Snowflake) -> Entitlement: + """|coro| + + Creates a test entitlement for the guild. + + Parameters + ---------- + sku: :class:`Snowflake` + The SKU to create a test entitlement for. + + Returns + ------- + :class:`Entitlement` + The created entitlement. + """ + payload = { + "sku_id": sku.id, + "owner_id": self.id, + "owner_type": EntitlementOwnerType.guild.value, + } + data = await self._state.http.create_test_entitlement(self.id, payload) + return Entitlement(data=data, state=self._state) diff --git a/discord/http.py b/discord/http.py index 9ab72b6252..6b36566d99 100644 --- a/discord/http.py +++ b/discord/http.py @@ -69,6 +69,7 @@ invite, member, message, + monetization, onboarding, role, scheduled_events, @@ -2885,6 +2886,55 @@ def update_application_role_connection_metadata_records( ) return self.request(r, json=payload) + # Monetization + + def list_skus( + self, + application_id: Snowflake, + ) -> Response[list[monetization.SKU]]: + r = Route( + "GET", + "/applications/{application_id}/skus", + application_id=application_id, + ) + return self.request(r) + + def list_entitlements( + self, + application_id: Snowflake, + ) -> Response[list[monetization.Entitlement]]: + r = Route( + "GET", + "/applications/{application_id}/entitlements", + application_id=application_id, + ) + return self.request(r) + + def create_test_entitlement( + self, + application_id: Snowflake, + payload: monetization.CreateTestEntitlementPayload, + ) -> Response[monetization.Entitlement]: + r = Route( + "POST", + "/applications/{application_id}/entitlements", + application_id=application_id, + ) + return self.request(r, json=payload) + + def delete_test_entitlement( + self, + application_id: Snowflake, + entitlement_id: Snowflake, + ) -> Response[None]: + r = Route( + "DELETE", + "/applications/{application_id}/entitlements/{entitlement_id}", + application_id=application_id, + entitlement_id=entitlement_id, + ) + return self.request(r) + # Onboarding def get_onboarding(self, guild_id: Snowflake) -> Response[onboarding.Onboarding]: diff --git a/discord/interactions.py b/discord/interactions.py index 19b5363082..6443af1c04 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -36,6 +36,7 @@ from .flags import MessageFlags from .member import Member from .message import Attachment, Message +from .monetization import Entitlement from .object import Object from .permissions import Permissions from .user import User @@ -183,6 +184,9 @@ def _from_data(self, data: InteractionPayload): self.data.get("custom_id") if self.data is not None else None ) self._app_permissions: int = int(data.get("app_permissions", 0)) + self.entitlements: list[Entitlement] = [ + Entitlement(data=e, state=self._state) for e in data.get("entitlements", []) + ] self.message: Message | None = None self.channel = None @@ -1185,6 +1189,37 @@ async def send_modal(self, modal: Modal) -> Interaction: self._parent._state.store_modal(modal, self._parent.user.id) return self._parent + async def premium_required(self) -> Interaction: + """|coro| + Responds to this interaction by sending a premium required message. + + Raises + ------ + HTTPException + Sending the message failed. + InteractionResponded + This interaction has already been responded to before. + """ + if self._responded: + raise InteractionResponded(self._parent) + + parent = self._parent + + adapter = async_context.get() + http = parent._state.http + await self._locked_response( + adapter.create_interaction_response( + parent.id, + parent.token, + session=parent._session, + proxy=http.proxy, + proxy_auth=http.proxy_auth, + type=InteractionResponseType.premium_required.value, + ) + ) + self._responded = True + return self._parent + async def _locked_response(self, coro: Coroutine[Any]): """|coro| diff --git a/discord/monetization.py b/discord/monetization.py new file mode 100644 index 0000000000..910ad90916 --- /dev/null +++ b/discord/monetization.py @@ -0,0 +1,176 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-2021 Rapptz +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .enums import EntitlementType, SKUType, try_enum +from .flags import SKUFlags +from .mixins import Hashable +from .utils import MISSING, _get_as_snowflake, parse_time + +if TYPE_CHECKING: + from datetime import datetime + + from .state import ConnectionState + from .types.monetization import SKU as SKUPayload + from .types.monetization import Entitlement as EntitlementPayload + + +__all__ = ( + "SKU", + "Entitlement", +) + + +class SKU(Hashable): + """Represents a Discord SKU (stock-keeping unit). + + .. versionadded:: 2.5 + + Attributes + ---------- + id: :class:`int` + The SKU's ID. + type: :class:`SKUType` + The type of SKU. + application_id: :class:`int` + The ID of the application this SKU belongs to. + name: :class:`str` + The name of the SKU. + slug: :class:`str` + The SKU's slug. + flags: :class:`SKUFlags` + The SKU's flags. + """ + + __slots__ = ( + "id", + "type", + "application_id", + "name", + "slug", + "flags", + ) + + def __init__(self, *, data: SKUPayload) -> None: + self.id: int = int(data["id"]) + self.type: SKUType = try_enum(SKUType, data["type"]) + self.application_id: int = int(data["application_id"]) + self.name: str = data["name"] + self.slug: str = data["slug"] + self.flags: SKUFlags = SKUFlags._from_value(data["flags"]) + + def __repr__(self) -> str: + return ( + f"" + ) + + def __str__(self) -> str: + return self.name + + def __eq__(self, other: object) -> bool: + return isinstance(other, self.__class__) and other.id == self.id + + +class Entitlement(Hashable): + """Represents a Discord entitlement. + + .. versionadded:: 2.5 + + Attributes + ---------- + id: :class:`int` + The entitlement's ID. + sku_id: :class:`int` + The ID of the SKU this entitlement is for. + application_id: :class:`int` + The ID of the application this entitlement belongs to. + user_id: Union[:class:`int`, :class:`MISSING`] + The ID of the user that owns this entitlement. + type: :class:`EntitlementType` + The type of entitlement. + deleted: :class:`bool` + Whether the entitlement has been deleted. + starts_at: Union[:class:`datetime.datetime`, :class:`MISSING`] + When the entitlement starts. + ends_at: Union[:class:`datetime.datetime`, :class:`MISSING`] + When the entitlement expires. + guild_id: Union[:class:`int`, :class:`MISSING`] + The ID of the guild that owns this entitlement. + """ + + __slots__ = ( + "_state", + "id", + "sku_id", + "application_id", + "user_id", + "type", + "deleted", + "starts_at", + "ends_at", + "guild_id", + ) + + def __init__(self, *, data: EntitlementPayload, state: ConnectionState) -> None: + self._state = state + self.id: int = int(data["id"]) + self.sku_id: int = int(data["sku_id"]) + self.application_id: int = int(data["application_id"]) + self.user_id: int | MISSING = _get_as_snowflake(data, "user_id") or MISSING + self.type: EntitlementType = try_enum(EntitlementType, data["type"]) + self.deleted: bool = data["deleted"] + self.starts_at: datetime | MISSING = ( + parse_time(data.get("starts_at")) or MISSING + ) + self.ends_at: datetime | MISSING = parse_time(data.get("ends_at")) or MISSING + self.guild_id: int | MISSING = _get_as_snowflake(data, "guild_id") or MISSING + + def __repr__(self) -> str: + return ( + f"" + ) + + def __eq__(self, other: object) -> bool: + return isinstance(other, self.__class__) and other.id == self.id + + async def delete(self) -> None: + """|coro| + + Deletes a test entitlement. + + A test entitlement is an entitlement that was created using :meth:`Guild.create_test_entitlement` or :meth:`User.create_test_entitlement`. + + Raises + ------ + HTTPException + Deleting the entitlement failed. + """ + await self._state.http.delete_test_entitlement(self.id) diff --git a/discord/state.py b/discord/state.py index d222ba4518..49f8acf024 100644 --- a/discord/state.py +++ b/discord/state.py @@ -59,6 +59,7 @@ from .member import Member from .mentions import AllowedMentions from .message import Message +from .monetization import Entitlement from .object import Object from .partial_emoji import PartialEmoji from .raw_models import * @@ -665,6 +666,18 @@ def parse_auto_moderation_action_execution(self, data) -> None: event = AutoModActionExecutionEvent(self, data) self.dispatch("auto_moderation_action_execution", event) + def parse_entitlement_create(self, data) -> None: + event = Entitlement(data=data, state=self) + self.dispatch("entitlement_create", event) + + def parse_entitlement_update(self, data) -> None: + event = Entitlement(data=data, state=self) + self.dispatch("entitlement_update", event) + + def parse_entitlement_delete(self, data) -> None: + event = Entitlement(data=data, state=self) + self.dispatch("entitlement_delete", event) + def parse_message_create(self, data) -> None: channel, _ = self._get_guild_channel(data) # channel would be the correct type here diff --git a/discord/types/interactions.py b/discord/types/interactions.py index db66489067..8ad0295b40 100644 --- a/discord/types/interactions.py +++ b/discord/types/interactions.py @@ -33,6 +33,7 @@ from .embed import Embed from .member import Member from .message import Attachment +from .monetization import Entitlement from .role import Role from .snowflake import Snowflake from .user import User @@ -111,7 +112,7 @@ class GuildApplicationCommandPermissions(PartialGuildApplicationCommandPermissio guild_id: Snowflake -InteractionType = Literal[1, 2, 3] +InteractionType = Literal[1, 2, 3, 4, 5] class _ApplicationCommandInteractionDataOption(TypedDict): @@ -219,6 +220,7 @@ class Interaction(TypedDict): type: InteractionType token: str version: int + entitlements: list[Entitlement] class InteractionApplicationCommandCallbackData(TypedDict, total=False): @@ -230,7 +232,7 @@ class InteractionApplicationCommandCallbackData(TypedDict, total=False): components: list[Component] -InteractionResponseType = Literal[1, 4, 5, 6, 7] +InteractionResponseType = Literal[1, 4, 5, 6, 7, 8, 9, 10] class InteractionResponse(TypedDict): diff --git a/discord/types/monetization.py b/discord/types/monetization.py new file mode 100644 index 0000000000..8b186c83e0 --- /dev/null +++ b/discord/types/monetization.py @@ -0,0 +1,62 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-2021 Rapptz +Copyright (c) 2021-present Pycord Development + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import Literal + +from .._typed_dict import NotRequired, TypedDict +from .snowflake import Snowflake + +SKUType = Literal[5, 6] +EntitlementType = Literal[8] +OwnerType = Literal[1, 2] + + +class SKU(TypedDict): + id: Snowflake + type: SKUType + application_id: Snowflake + name: str + slug: str + flags: int + + +class Entitlement(TypedDict): + id: Snowflake + sku_id: Snowflake + application_id: Snowflake + user_id: NotRequired[Snowflake] + type: EntitlementType + deleted: bool + starts_at: NotRequired[str] + ends_at: NotRequired[str] + guild_id: NotRequired[Snowflake] + + +class CreateTestEntitlementPayload(TypedDict): + sku_id: Snowflake + owner_id: Snowflake + owner_type: OwnerType diff --git a/discord/user.py b/discord/user.py index b1aecee370..1f11574a1c 100644 --- a/discord/user.py +++ b/discord/user.py @@ -32,6 +32,7 @@ from .asset import Asset from .colour import Colour from .flags import PublicUserFlags +from .monetization import Entitlement from .utils import MISSING, _bytes_to_base64_data, snowflake_time if TYPE_CHECKING: @@ -594,3 +595,26 @@ async def create_dm(self) -> DMChannel: state = self._state data: DMChannelPayload = await state.http.start_private_message(self.id) return state.add_dm_channel(data) + + async def create_test_entitlement(self, sku: discord.abc.Snowflake) -> Entitlement: + """|coro| + + Creates a test entitlement for the user. + + Parameters + ---------- + sku: :class:`Snowflake` + The SKU to create a test entitlement for. + + Returns + ------- + :class:`Entitlement` + The created entitlement. + """ + payload = { + "sku_id": sku.id, + "owner_id": self.id, + "owner_type": 2, + } + data = await self._state.http.create_test_entitlement(self.id, payload) + return Entitlement(data=data, state=self._state) diff --git a/docs/api/data_classes.rst b/docs/api/data_classes.rst index 735f20d68b..5df7a15fd4 100644 --- a/docs/api/data_classes.rst +++ b/docs/api/data_classes.rst @@ -133,6 +133,11 @@ Flags .. autoclass:: ChannelFlags() :members: +.. attributetable:: SKUFlags + +.. autoclass:: SKUFlags() + :members: + Colour ------ diff --git a/docs/api/enums.rst b/docs/api/enums.rst index 77e2cea86d..431934cc65 100644 --- a/docs/api/enums.rst +++ b/docs/api/enums.rst @@ -2334,3 +2334,43 @@ of :class:`enum.Enum`. .. attribute:: burst Represents a super reaction. + +.. class:: SKUType + + Represents an SKU's type. + + .. versionadded:: 2.5 + + .. attribute:: subscription + + Represents a recurring subscription. + + .. attribute:: subscription_group + + A system-generated group for each subscription SKU created. These types of SKUs are currently unused. + + +.. class:: EntitlementType + + Represents an entitlement's type. + + .. versionadded:: 2.5 + + .. attribute:: app_subscription + + Entitlement was purchased as an app subscription. + + +.. class:: EntitlementOwnerType + + Represents an entitlement's ownership type. + + .. versionadded:: 2.5 + + .. attribute:: guild + + Entitlement is owned by a guild. + + .. attribute:: user + + Entitlement is owned by a user. diff --git a/docs/api/events.rst b/docs/api/events.rst index 2eb92c19b5..8a0b76d13b 100644 --- a/docs/api/events.rst +++ b/docs/api/events.rst @@ -410,6 +410,42 @@ Connection WebSocket library. It can be :class:`bytes` to denote a binary message or :class:`str` to denote a regular text message. +Entitlements +------------ +.. function:: on_entitlement_create(entitlement) + + Called when a user subscribes to an SKU. + + .. versionadded:: 2.5 + + :param entitlement: The entitlement that was created as a result of the subscription. + :type entitlement: :class:`Entitlement` + +.. function:: on_entitlement_update(entitlement) + + Called when a user's subscription to an Entitlement is renewed for the next billing period. + + .. versionadded:: 2.5 + + :param entitlement: The entitlement that was updated. + :type entitlement: :class:`Entitlement` + +.. function:: on_entitlement_delete(entitlement) + + Called when a user's entitlement is deleted. + + Entitlements are usually only deleted when Discord issues a refund for a subscription, + or manually removes an entitlement from a user. + + .. note:: + + This is not called when a user's subscription is cancelled. + + .. versionadded:: 2.5 + + :param entitlement: The entitlement that was deleted. + :type entitlement: :class:`Entitlement` + Guilds ------ .. function:: on_guild_join(guild) diff --git a/docs/api/models.rst b/docs/api/models.rst index b2f85185b6..4fce609baf 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -101,6 +101,19 @@ Messages .. autoclass:: ReactionCountDetails() :members: +Monetization +------------ + +.. attributetable:: SKU + +.. autoclass:: SKU() + :members: + +.. attributetable:: Entitlement + +.. autoclass:: Entitlement() + :members: + Guild -----