diff --git a/changelog/1113.feature.rst b/changelog/1113.feature.rst index 079ad452e1..e9c1c6b123 100644 --- a/changelog/1113.feature.rst +++ b/changelog/1113.feature.rst @@ -1,5 +1,5 @@ Support application subscriptions and one-time purchases (see the :ddocs:`official docs ` for more info). -- New types: :class:`SKU`, :class:`Entitlement`. +- New types: :class:`SKU`, :class:`Entitlement`, :class:`Subscription`. - New :attr:`Interaction.entitlements` attribute, and :meth:`InteractionResponse.require_premium` response type. -- New events: :func:`on_entitlement_create`, :func:`on_entitlement_update`, :func:`on_entitlement_delete`. +- New events: :func:`on_entitlement_create`, :func:`on_entitlement_update`, :func:`on_entitlement_delete`, :func:`on_subscription_create`, :func:`on_subscription_update` and :func:`on_subscription_delete`. - New :class:`Client` methods: :meth:`~Client.skus`, :meth:`~Client.entitlements`, :meth:`~Client.fetch_entitlement`, :meth:`~Client.create_entitlement`. diff --git a/changelog/1186.feature.rst b/changelog/1186.feature.rst index 079ad452e1..e9c1c6b123 100644 --- a/changelog/1186.feature.rst +++ b/changelog/1186.feature.rst @@ -1,5 +1,5 @@ Support application subscriptions and one-time purchases (see the :ddocs:`official docs ` for more info). -- New types: :class:`SKU`, :class:`Entitlement`. +- New types: :class:`SKU`, :class:`Entitlement`, :class:`Subscription`. - New :attr:`Interaction.entitlements` attribute, and :meth:`InteractionResponse.require_premium` response type. -- New events: :func:`on_entitlement_create`, :func:`on_entitlement_update`, :func:`on_entitlement_delete`. +- New events: :func:`on_entitlement_create`, :func:`on_entitlement_update`, :func:`on_entitlement_delete`, :func:`on_subscription_create`, :func:`on_subscription_update` and :func:`on_subscription_delete`. - New :class:`Client` methods: :meth:`~Client.skus`, :meth:`~Client.entitlements`, :meth:`~Client.fetch_entitlement`, :meth:`~Client.create_entitlement`. diff --git a/changelog/1249.feature.rst b/changelog/1249.feature.rst index 079ad452e1..e9c1c6b123 100644 --- a/changelog/1249.feature.rst +++ b/changelog/1249.feature.rst @@ -1,5 +1,5 @@ Support application subscriptions and one-time purchases (see the :ddocs:`official docs ` for more info). -- New types: :class:`SKU`, :class:`Entitlement`. +- New types: :class:`SKU`, :class:`Entitlement`, :class:`Subscription`. - New :attr:`Interaction.entitlements` attribute, and :meth:`InteractionResponse.require_premium` response type. -- New events: :func:`on_entitlement_create`, :func:`on_entitlement_update`, :func:`on_entitlement_delete`. +- New events: :func:`on_entitlement_create`, :func:`on_entitlement_update`, :func:`on_entitlement_delete`, :func:`on_subscription_create`, :func:`on_subscription_update` and :func:`on_subscription_delete`. - New :class:`Client` methods: :meth:`~Client.skus`, :meth:`~Client.entitlements`, :meth:`~Client.fetch_entitlement`, :meth:`~Client.create_entitlement`. diff --git a/changelog/1257.feature.rst b/changelog/1257.feature.rst new file mode 100644 index 0000000000..e9c1c6b123 --- /dev/null +++ b/changelog/1257.feature.rst @@ -0,0 +1,5 @@ +Support application subscriptions and one-time purchases (see the :ddocs:`official docs ` for more info). +- New types: :class:`SKU`, :class:`Entitlement`, :class:`Subscription`. +- New :attr:`Interaction.entitlements` attribute, and :meth:`InteractionResponse.require_premium` response type. +- New events: :func:`on_entitlement_create`, :func:`on_entitlement_update`, :func:`on_entitlement_delete`, :func:`on_subscription_create`, :func:`on_subscription_update` and :func:`on_subscription_delete`. +- New :class:`Client` methods: :meth:`~Client.skus`, :meth:`~Client.entitlements`, :meth:`~Client.fetch_entitlement`, :meth:`~Client.create_entitlement`. diff --git a/disnake/__init__.py b/disnake/__init__.py index 705de41175..e2658fed15 100644 --- a/disnake/__init__.py +++ b/disnake/__init__.py @@ -66,6 +66,7 @@ from .soundboard import * from .stage_instance import * from .sticker import * +from .subscription import * from .team import * from .template import * from .threads import * diff --git a/disnake/client.py b/disnake/client.py index 23cc8ae626..c87213d2f2 100644 --- a/disnake/client.py +++ b/disnake/client.py @@ -3180,7 +3180,7 @@ async def skus(self) -> List[SKU]: The list of SKUs. """ data = await self.http.get_skus(self.application_id) - return [SKU(data=d) for d in data] + return [SKU(data=d, state=self._connection) for d in data] def entitlements( self, diff --git a/disnake/entitlement.py b/disnake/entitlement.py index b27fff44db..8dfda2c745 100644 --- a/disnake/entitlement.py +++ b/disnake/entitlement.py @@ -76,7 +76,6 @@ class Entitlement(Hashable): Set to ``None`` when this is a test entitlement. ends_at: Optional[:class:`datetime.datetime`] The time at which the entitlement stops being active. - Set to ``None`` when this is a test entitlement. You can use :meth:`is_active` to check whether this entitlement is still active. """ diff --git a/disnake/enums.py b/disnake/enums.py index 1707d0976b..f6eef0fb7d 100644 --- a/disnake/enums.py +++ b/disnake/enums.py @@ -72,6 +72,7 @@ "OnboardingPromptType", "SKUType", "EntitlementType", + "SubscriptionStatus", "PollLayoutType", "VoiceChannelEffectAnimationType", "MessageReferenceType", @@ -1340,7 +1341,22 @@ class Event(Enum): """ entitlement_delete = "entitlement_delete" """Called when a user's entitlement is deleted. - Represents the :func:`on_entitlement_delete` event. + Represents the :func:`on_entitlement_delete` event.""" + subscription_create = "subscription_create" + """Called when a subscription for a premium app is created. + Represents the :func:`on_subscription_create` event. + + .. versionadded:: 2.10 + """ + subscription_update = "subscription_update" + """Called when a subscription for a premium app is updated. + Represents the :func:`on_subscription_update` event. + + .. versionadded:: 2.10 + """ + subscription_delete = "subscription_delete" + """Called when a subscription for a premium app is deleted. + Represents the :func:`on_subscription_delete` event. .. versionadded:: 2.10 """ @@ -1429,6 +1445,12 @@ class EntitlementType(Enum): application_subscription = 8 +class SubscriptionStatus(Enum): + active = 0 + ending = 1 + inactive = 2 + + class PollLayoutType(Enum): default = 1 diff --git a/disnake/http.py b/disnake/http.py index 8304d780d4..4d3132c2ea 100644 --- a/disnake/http.py +++ b/disnake/http.py @@ -75,6 +75,7 @@ sku, soundboard, sticker, + subscription, template, threads, user, @@ -2412,6 +2413,46 @@ def get_entitlement( ) ) + def get_subscriptions( + self, + sku_id: Snowflake, + *, + before: Optional[Snowflake] = None, + after: Optional[Snowflake] = None, + limit: int = 50, + user_id: Optional[Snowflake] = None, + ) -> Response[List[subscription.Subscription]]: + params: Dict[str, Any] = { + "limit": limit, + } + if before is not None: + params["before"] = before + if after is not None: + params["after"] = after + if user_id is not None: + params["user_id"] = user_id + + return self.request( + Route( + "GET", + "/skus/{sku_id}/subscriptions", + sku_id=sku_id, + ), + params=params, + ) + + def get_subscription( + self, sku_id: Snowflake, subscription_id: int + ) -> Response[subscription.Subscription]: + return self.request( + Route( + "GET", + "/skus/{sku_id}/subscriptions/{subscription_id}", + sku_id=sku_id, + subscription_id=subscription_id, + ) + ) + def create_test_entitlement( self, application_id: Snowflake, diff --git a/disnake/iterators.py b/disnake/iterators.py index 86c311e39f..847041b7ac 100644 --- a/disnake/iterators.py +++ b/disnake/iterators.py @@ -27,6 +27,7 @@ from .guild_scheduled_event import GuildScheduledEvent from .integrations import PartialIntegration from .object import Object +from .subscription import Subscription from .threads import Thread from .utils import maybe_coroutine, snowflake_time, time_snowflake @@ -39,6 +40,7 @@ "MemberIterator", "GuildScheduledEventUserIterator", "EntitlementIterator", + "SubscriptionIterator", "PollAnswerIterator", ) @@ -60,6 +62,7 @@ GuildScheduledEventUser as GuildScheduledEventUserPayload, ) from .types.message import Message as MessagePayload + from .types.subscription import Subscription as SubscriptionPayload from .types.threads import Thread as ThreadPayload from .types.user import PartialUser as PartialUserPayload from .user import User @@ -1147,6 +1150,104 @@ async def _after_strategy(self, retrieve: int) -> List[EntitlementPayload]: return data +class SubscriptionIterator(_AsyncIterator["Subscription"]): + def __init__( + self, + sku_id: int, + *, + state: ConnectionState, + user_id: Optional[int] = None, # required, except for oauth queries + limit: Optional[int] = None, + before: Optional[Union[Snowflake, datetime.datetime]] = None, + after: Optional[Union[Snowflake, datetime.datetime]] = None, + ) -> None: + if isinstance(before, datetime.datetime): + before = Object(id=time_snowflake(before, high=False)) + if isinstance(after, datetime.datetime): + after = Object(id=time_snowflake(after, high=True)) + + self.sku_id: int = sku_id + self.user_id: Optional[int] = user_id + self.limit: Optional[int] = limit + self.before: Optional[Snowflake] = before + self.after: Snowflake = after or OLDEST_OBJECT + + self._state: ConnectionState = state + self.request = self._state.http.get_subscriptions + self.subscriptions: asyncio.Queue[Subscription] = asyncio.Queue() + + self._filter: Optional[Callable[[SubscriptionPayload], bool]] = None + if self.before: + self._strategy = self._before_strategy + if self.after != OLDEST_OBJECT: + self._filter = lambda s: int(s["id"]) > self.after.id + else: + self._strategy = self._after_strategy + + async def next(self) -> Subscription: + if self.subscriptions.empty(): + await self._fill() + + try: + return self.subscriptions.get_nowait() + except asyncio.QueueEmpty: + raise NoMoreItems from None + + def _get_retrieve(self) -> bool: + limit = self.limit + if limit is None or limit > 100: + retrieve = 100 + else: + retrieve = limit + self.retrieve: int = retrieve + return retrieve > 0 + + async def _fill(self) -> None: + if not self._get_retrieve(): + return + + data = await self._strategy(self.retrieve) + if len(data) < 100: + self.limit = 0 # terminate loop + + if self._filter: + data = filter(self._filter, data) + + for subscription in data: + await self.subscriptions.put(Subscription(data=subscription, state=self._state)) + + async def _before_strategy(self, retrieve: int) -> List[SubscriptionPayload]: + before = self.before.id if self.before else None + data = await self.request( + self.sku_id, + before=before, + limit=retrieve, + user_id=self.user_id, + ) + + if len(data): + if self.limit is not None: + self.limit -= retrieve + # since pagination order isn't documented, don't rely on results being sorted one way or the other + self.before = Object(id=min(int(data[0]["id"]), int(data[-1]["id"]))) + return data + + async def _after_strategy(self, retrieve: int) -> List[SubscriptionPayload]: + after = self.after.id + data = await self.request( + self.sku_id, + after=after, + limit=retrieve, + user_id=self.user_id, + ) + + if len(data): + if self.limit is not None: + self.limit -= retrieve + self.after = Object(id=max(int(data[0]["id"]), int(data[-1]["id"]))) + return data + + class PollAnswerIterator(_AsyncIterator[Union["User", "Member"]]): def __init__( self, diff --git a/disnake/sku.py b/disnake/sku.py index 204720d6a3..3b9e4fabb3 100644 --- a/disnake/sku.py +++ b/disnake/sku.py @@ -3,14 +3,18 @@ from __future__ import annotations import datetime -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from .enums import SKUType, try_enum from .flags import SKUFlags +from .iterators import SubscriptionIterator from .mixins import Hashable +from .subscription import Subscription from .utils import snowflake_time if TYPE_CHECKING: + from .abc import Snowflake, SnowflakeTime + from .state import ConnectionState from .types.sku import SKU as SKUPayload @@ -56,9 +60,10 @@ class SKU(Hashable): The SKU's URL slug, system-generated based on :attr:`name`. """ - __slots__ = ("id", "type", "application_id", "name", "slug", "_flags") + __slots__ = ("_state", "id", "type", "application_id", "name", "slug", "_flags") - def __init__(self, *, data: SKUPayload) -> None: + def __init__(self, *, data: SKUPayload, state: ConnectionState) -> None: + self._state: ConnectionState = state self.id: int = int(data["id"]) self.type: SKUType = try_enum(SKUType, data["type"]) self.application_id: int = int(data["application_id"]) @@ -81,3 +86,69 @@ def created_at(self) -> datetime.datetime: def flags(self) -> SKUFlags: """:class:`SKUFlags`: Returns the SKU's flags.""" return SKUFlags._from_value(self._flags) + + async def subscriptions( + self, + user: Snowflake, + *, + limit: Optional[int] = 50, + before: Optional[SnowflakeTime] = None, + after: Optional[SnowflakeTime] = None, + ) -> SubscriptionIterator: + """|coro| + + Retrieves an :class:`.AsyncIterator` that enables receiving subscriptions for the SKU. + + All parameters, except ``user``, are optional. + + Parameters + ---------- + user: :class:`abc.Snowflake` + The user to retrieve subscriptions for. + limit: Optional[:class:`int`] + The number of subscriptions to retrieve. + If ``None``, retrieves every subscription. + Note, however, that this would make it a slow operation. + Defaults to ``50``. + before: Union[:class:`.abc.Snowflake`, :class:`datetime.datetime`] + Retrieves subscriptions created before this date or object. + If a datetime is provided, it is recommended to use a UTC aware datetime. + If the datetime is naive, it is assumed to be local time. + after: Union[:class:`.abc.Snowflake`, :class:`datetime.datetime`] + Retrieve subscriptions created after this date or object. + If a datetime is provided, it is recommended to use a UTC aware datetime. + If the datetime is naive, it is assumed to be local time. + + Raises + ------ + HTTPException + Retrieving the subscriptions failed. + + Yields + ------ + :class:`.Subscription` + The subscriptions for the given parameters. + """ + return SubscriptionIterator( + self.id, + state=self._state, + user_id=user.id, + limit=limit, + before=before, + after=after, + ) + + async def fetch_subscription(self, subscription_id: int, /) -> Subscription: + """|coro| + + Retrieve a subscription for this SKU given its ID. + + Raises + ------ + NotFound + The subscription does not exist. + HTTPException + Retrieving the subscription failed. + """ + data = await self._state.http.get_subscription(self.id, subscription_id) + return Subscription(data=data, state=self._state) diff --git a/disnake/subscription.py b/disnake/subscription.py new file mode 100644 index 0000000000..bf86e1a35a --- /dev/null +++ b/disnake/subscription.py @@ -0,0 +1,123 @@ +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +import datetime +from typing import TYPE_CHECKING, List, Optional + +from .enums import SubscriptionStatus, try_enum +from .mixins import Hashable +from .utils import parse_time, snowflake_time + +if TYPE_CHECKING: + from .state import ConnectionState + from .types.subscription import Subscription as SubscriptionPayload + from .user import User + +__all__ = ("Subscription",) + + +class Subscription(Hashable): + """Represents a subscription. + + This can only be retrieved using :meth:`SKU.subscriptions` or :meth:`SKU.fetch_subscription`, + or provided by events (e.g. :func:`on_subscription_create`). + + .. warning:: + :class:`Subscription`\\s should not be used to grant perks. Use :class:`Entitlement`\\s as a way of determining whether a user should have access to a specific :class:`SKU`. + + .. note:: + Some subscriptions may have been canceled already; consider using :meth:`is_canceled` to check whether a given subscription was canceled. + + .. collapse:: operations + + .. describe:: x == y + + Checks if two :class:`Subscription`\\s are equal. + + .. describe:: x != y + + Checks if two :class:`Subscription`\\s are not equal. + + .. describe:: hash(x) + + Returns the subscription's hash. + + .. versionadded:: 2.10 + + Attributes + ---------- + id: :class:`int` + The subscription's ID. + user_id: :class:`int` + The ID of the user who is subscribed to the :attr:`sku_ids`. + + See also :attr:`user`. + sku_ids: List[:class:`int`] + The ID of the SKUs the user is subscribed to. + renewal_sku_ids: List[:class:`int`] + The IDs of the SKUs that will be renewed at the start of the new period. + entitlement_ids: List[:class:`int`] + The IDs of the entitlements the user has as part of this subscription. + current_period_start: :class:`datetime.datetime` + The time at which the current period for the given subscription started. + current_period_end: :class:`datetime.datetime` + The time at which the current period for the given subscription will end. + status: :class:`SubscriptionStatus` + The current status of the given subscription. + canceled_at: Optional[:class:`datetime.datetime`] + The time at which the subscription was canceled. + + See also :attr:`is_canceled`. + """ + + __slots__ = ( + "_state", + "id", + "user_id", + "sku_ids", + "entitlement_ids", + "renewal_sku_ids", + "current_period_start", + "current_period_end", + "status", + "canceled_at", + ) + + def __init__(self, *, data: SubscriptionPayload, state: ConnectionState) -> None: + self._state: ConnectionState = state + + self.id: int = int(data["id"]) + self.user_id: int = int(data["user_id"]) + self.sku_ids: List[int] = list(map(int, data["sku_ids"])) + self.entitlement_ids: List[int] = list(map(int, data["entitlement_ids"])) + self.renewal_sku_ids: Optional[List[int]] = ( + list(map(int, renewal_sku_ids)) + if (renewal_sku_ids := data.get("renewal_sku_ids")) is not None + else None + ) + self.current_period_start: datetime.datetime = parse_time(data["current_period_start"]) + self.current_period_end: datetime.datetime = parse_time(data["current_period_end"]) + self.status: SubscriptionStatus = try_enum(SubscriptionStatus, data["status"]) + self.canceled_at: Optional[datetime.datetime] = parse_time(data["canceled_at"]) + + @property + def created_at(self) -> datetime.datetime: + """:class:`datetime.datetime`: Returns the subscription's creation time in UTC.""" + return snowflake_time(self.id) + + @property + def user(self) -> Optional[User]: + """Optional[:class:`User`]: The user who is subscribed to the :attr:`sku_ids`. + + Requires the user to be cached. + See also :attr:`user_id`. + """ + return self._state.get_user(self.user_id) + + @property + def is_canceled(self) -> bool: + """:class:`bool`: Whether the subscription was canceled, + based on :attr:`canceled_at`. + """ + return self.canceled_at is not None diff --git a/disnake/types/subscription.py b/disnake/types/subscription.py new file mode 100644 index 0000000000..1cfaaf8684 --- /dev/null +++ b/disnake/types/subscription.py @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: MIT + +from typing import List, Literal, Optional, TypedDict + +from typing_extensions import NotRequired + +from .snowflake import Snowflake + +SubscriptionStatus = Literal[0, 1, 2] + + +class Subscription(TypedDict): + id: Snowflake + user_id: Snowflake + sku_ids: List[Snowflake] + entitlement_ids: List[Snowflake] + renewal_sku_ids: Optional[List[Snowflake]] + current_period_start: str + current_period_end: str + status: SubscriptionStatus + canceled_at: Optional[str] + # this is always missing unless queried with a private OAuth scope. + country: NotRequired[str] diff --git a/docs/api/events.rst b/docs/api/events.rst index bd4f16652a..99599fa400 100644 --- a/docs/api/events.rst +++ b/docs/api/events.rst @@ -1580,8 +1580,8 @@ This section documents events related to entitlements, which are used for applic Called when an entitlement is updated. - This happens e.g. when a user's subscription gets renewed (in which case the - :attr:`Entitlement.ends_at` attribute reflects the new expiration date). + This happens **only** when a user's subscription ends or is cancelled (in which case the + :attr:`Entitlement.ends_at` attribute reflects the expiration date). .. versionadded:: 2.10 @@ -1601,6 +1601,33 @@ This section documents events related to entitlements, which are used for applic :param entitlement: The entitlement that was deleted. :type entitlement: :class:`Entitlement` +.. function:: on_subscription_create(subscription) + + Called when a subscription is created. + + .. versionadded:: 2.10 + + :param subscription: The subscription that was created. + :type subscription: :class:`Subscription` + +.. function:: on_subscription_update(subscription) + + Called when a subscription is updated. + + .. versionadded:: 2.10 + + :param subscription: The subscription that was updated. + :type subscription: :class:`Subscription` + +.. function:: on_subscription_delete(subscription) + + Called when a subscription is deleted. + + .. versionadded:: 2.10 + + :param subscription: The subscription that was deleted. + :type subscription: :class:`Subscription` + Enumerations ------------ diff --git a/docs/api/index.rst b/docs/api/index.rst index c29ba29e6f..0b04ea5e1d 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -116,6 +116,7 @@ Documents permissions roles skus + subscriptions soundboard stage_instances stickers diff --git a/docs/api/subscriptions.rst b/docs/api/subscriptions.rst new file mode 100644 index 0000000000..338cb9e786 --- /dev/null +++ b/docs/api/subscriptions.rst @@ -0,0 +1,44 @@ +.. SPDX-License-Identifier: MIT + +.. currentmodule:: disnake + +Subscriptions +=============== + +This section documents everything related to Subscription(s), which represents a user making recurring payments for at least one SKU. +See the :ddocs:`official docs ` for more info. + +Discord Models +-------------- + +Subscription +~~~~~~~~~~~~ + +.. attributetable:: Subscription + +.. autoclass:: Subscription() + :members: + +Enumerations +------------ + +SubscriptionStatus +~~~~~~~~~~~~~~~~~~ + +.. class:: SubscriptionStatus + + Represents the status of a subscription. + + .. versionadded:: 2.10 + + .. attribute:: active + + Represents an active Subscription which is scheduled to renew. + + .. attribute:: ending + + Represents an active Subscription which will not renew. + + .. attribute:: inactive + + Represents an inactive Subscription which is not being charged.