diff --git a/discord/__init__.py b/discord/__init__.py index 780460dc5..c206f650f 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -71,6 +71,7 @@ from .threads import * from .automod import * from .poll import * from .soundboard import * +from .subscription import * class VersionInfo(NamedTuple): diff --git a/discord/client.py b/discord/client.py index 6ee4a003d..ff02bf7b6 100644 --- a/discord/client.py +++ b/discord/client.py @@ -119,6 +119,7 @@ if TYPE_CHECKING: from .voice_client import VoiceProtocol from .audit_logs import AuditLogEntry from .poll import PollAnswer + from .subscription import Subscription # fmt: off @@ -1373,6 +1374,18 @@ class Client: ) -> Union[str, bytes]: ... + # Entitlements + @overload + async def wait_for( + self, + event: Literal['entitlement_create', 'entitlement_update', 'entitlement_delete'], + /, + *, + check: Optional[Callable[[Entitlement], bool]], + timeout: Optional[float] = None, + ) -> Entitlement: + ... + # Guilds @overload @@ -1781,6 +1794,18 @@ class Client: ) -> Coroutine[Any, Any, Tuple[StageInstance, StageInstance]]: ... + # Subscriptions + @overload + async def wait_for( + self, + event: Literal['subscription_create', 'subscription_update', 'subscription_delete'], + /, + *, + check: Optional[Callable[[Subscription], bool]], + timeout: Optional[float] = None, + ) -> Subscription: + ... + # Threads @overload async def wait_for( diff --git a/discord/enums.py b/discord/enums.py index 9000c8c04..3aecfc92b 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -75,6 +75,7 @@ __all__ = ( 'EntitlementOwnerType', 'PollLayoutType', 'VoiceChannelEffectAnimationType', + 'SubscriptionStatus', ) @@ -847,6 +848,12 @@ class VoiceChannelEffectAnimationType(Enum): basic = 1 +class SubscriptionStatus(Enum): + active = 0 + ending = 1 + inactive = 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/http.py b/discord/http.py index 3c1eacb61..8bd7a9804 100644 --- a/discord/http.py +++ b/discord/http.py @@ -94,6 +94,7 @@ if TYPE_CHECKING: poll, voice, soundboard, + subscription, ) from .types.snowflake import Snowflake, SnowflakeList @@ -2699,6 +2700,49 @@ class HTTPClient: ) ) + # Subscriptions + + def list_sku_subscriptions( + self, + sku_id: Snowflake, + before: Optional[Snowflake] = None, + after: Optional[Snowflake] = None, + limit: Optional[int] = None, + user_id: Optional[Snowflake] = None, + ) -> Response[List[subscription.Subscription]]: + params = {} + + if before is not None: + params['before'] = before + + if after is not None: + params['after'] = after + + if limit is not None: + params['limit'] = limit + + 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_sku_subscription(self, sku_id: Snowflake, subscription_id: Snowflake) -> Response[subscription.Subscription]: + return self.request( + Route( + 'GET', + '/skus/{sku_id}/subscriptions/{subscription_id}', + sku_id=sku_id, + subscription_id=subscription_id, + ) + ) + # Misc async def get_bot_gateway(self) -> Tuple[int, str]: diff --git a/discord/sku.py b/discord/sku.py index e8780399c..9ad325366 100644 --- a/discord/sku.py +++ b/discord/sku.py @@ -25,16 +25,18 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations -from typing import Optional, TYPE_CHECKING +from typing import AsyncIterator, Optional, TYPE_CHECKING from . import utils -from .errors import MissingApplicationID from .enums import try_enum, SKUType, EntitlementType from .flags import SKUFlags +from .object import Object +from .subscription import Subscription if TYPE_CHECKING: from datetime import datetime + from .abc import SnowflakeTime, Snowflake from .guild import Guild from .state import ConnectionState from .types.sku import ( @@ -100,6 +102,149 @@ class SKU: """:class:`datetime.datetime`: Returns the sku's creation time in UTC.""" return utils.snowflake_time(self.id) + async def fetch_subscription(self, subscription_id: int, /) -> Subscription: + """|coro| + + Retrieves a :class:`.Subscription` with the specified ID. + + .. versionadded:: 2.5 + + Parameters + ----------- + subscription_id: :class:`int` + The subscription's ID to fetch from. + + Raises + ------- + NotFound + An subscription with this ID does not exist. + HTTPException + Fetching the subscription failed. + + Returns + -------- + :class:`.Subscription` + The subscription you requested. + """ + data = await self._state.http.get_sku_subscription(self.id, subscription_id) + return Subscription(data=data, state=self._state) + + async def subscriptions( + self, + *, + limit: Optional[int] = 50, + before: Optional[SnowflakeTime] = None, + after: Optional[SnowflakeTime] = None, + user: Snowflake, + ) -> AsyncIterator[Subscription]: + """Retrieves an :term:`asynchronous iterator` of the :class:`.Subscription` that SKU has. + + .. versionadded:: 2.5 + + Examples + --------- + + Usage :: + + async for subscription in sku.subscriptions(limit=100): + print(subscription.user_id, subscription.current_period_end) + + Flattening into a list :: + + subscriptions = [subscription async for subscription in sku.subscriptions(limit=100)] + # subscriptions is now a list of Subscription... + + All parameters are optional. + + Parameters + ----------- + limit: Optional[:class:`int`] + The number of subscriptions to retrieve. If ``None``, it retrieves every subscription for this SKU. + Note, however, that this would make it a slow operation. Defaults to ``100``. + before: Optional[Union[:class:`~discord.abc.Snowflake`, :class:`datetime.datetime`]] + Retrieve subscriptions before this date or entitlement. + 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: Optional[Union[:class:`~discord.abc.Snowflake`, :class:`datetime.datetime`]] + Retrieve subscriptions after this date or entitlement. + 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. + user: :class:`~discord.abc.Snowflake` + The user to filter by. + + Raises + ------- + HTTPException + Fetching the subscriptions failed. + TypeError + Both ``after`` and ``before`` were provided, as Discord does not + support this type of pagination. + + Yields + -------- + :class:`.Subscription` + The subscription with the SKU. + """ + + if before is not None and after is not None: + raise TypeError('subscriptions pagination does not support both before and after') + + # This endpoint paginates in ascending order. + endpoint = self._state.http.list_sku_subscriptions + + async def _before_strategy(retrieve: int, before: Optional[Snowflake], limit: Optional[int]): + before_id = before.id if before else None + data = await endpoint(self.id, before=before_id, limit=retrieve, user_id=user.id) + + if data: + if limit is not None: + limit -= len(data) + + before = Object(id=int(data[0]['id'])) + + return data, before, limit + + async def _after_strategy(retrieve: int, after: Optional[Snowflake], limit: Optional[int]): + after_id = after.id if after else None + data = await endpoint( + self.id, + after=after_id, + limit=retrieve, + user_id=user.id, + ) + + if data: + if limit is not None: + limit -= len(data) + + after = Object(id=int(data[-1]['id'])) + + return data, after, limit + + if isinstance(before, datetime): + before = Object(id=utils.time_snowflake(before, high=False)) + if isinstance(after, datetime): + after = Object(id=utils.time_snowflake(after, high=True)) + + if before: + strategy, state = _before_strategy, before + else: + strategy, state = _after_strategy, after + + while True: + retrieve = 100 if limit is None else min(limit, 100) + if retrieve < 1: + return + + data, state, limit = await strategy(retrieve, state, limit) + + # Terminate loop on next iteration; there's no data left after this + if len(data) < 1000: + limit = 0 + + for e in data: + yield Subscription(data=e, state=self._state) + class Entitlement: """Represents an entitlement from user or guild which has been granted access to a premium offering. @@ -190,17 +335,12 @@ class Entitlement: Raises ------- - MissingApplicationID - The application ID could not be found. NotFound The entitlement could not be found. HTTPException Consuming the entitlement failed. """ - if self.application_id is None: - raise MissingApplicationID - await self._state.http.consume_entitlement(self.application_id, self.id) async def delete(self) -> None: @@ -210,15 +350,10 @@ class Entitlement: Raises ------- - MissingApplicationID - The application ID could not be found. NotFound The entitlement could not be found. HTTPException Deleting the entitlement failed. """ - if self.application_id is None: - raise MissingApplicationID - await self._state.http.delete_entitlement(self.application_id, self.id) diff --git a/discord/state.py b/discord/state.py index 91fd915bf..83628af32 100644 --- a/discord/state.py +++ b/discord/state.py @@ -79,6 +79,8 @@ from .automod import AutoModRule, AutoModAction from .audit_logs import AuditLogEntry from ._types import ClientT from .soundboard import SoundboardSound +from .subscription import Subscription + if TYPE_CHECKING: from .abc import PrivateChannel @@ -1736,6 +1738,18 @@ class ConnectionState(Generic[ClientT]): if poll: self.dispatch('poll_vote_remove', user, poll.get_answer(raw.answer_id)) + def parse_subscription_create(self, data: gw.SubscriptionCreateEvent) -> None: + subscription = Subscription(data=data, state=self) + self.dispatch('subscription_create', subscription) + + def parse_subscription_update(self, data: gw.SubscriptionUpdateEvent) -> None: + subscription = Subscription(data=data, state=self) + self.dispatch('subscription_update', subscription) + + def parse_subscription_delete(self, data: gw.SubscriptionDeleteEvent) -> None: + subscription = Subscription(data=data, state=self) + self.dispatch('subscription_delete', subscription) + def _get_reaction_user(self, channel: MessageableChannel, user_id: int) -> Optional[Union[User, Member]]: if isinstance(channel, (TextChannel, Thread, VoiceChannel)): return channel.guild.get_member(user_id) diff --git a/discord/subscription.py b/discord/subscription.py new file mode 100644 index 000000000..d861615ab --- /dev/null +++ b/discord/subscription.py @@ -0,0 +1,103 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +import datetime +from typing import List, Optional, TYPE_CHECKING + +from . import utils +from .mixins import Hashable +from .enums import try_enum, SubscriptionStatus + +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 Discord subscription. + + .. versionadded:: 2.5 + + Attributes + ----------- + id: :class:`int` + The subscription's ID. + user_id: :class:`int` + The ID of the user that is subscribed. + sku_ids: List[:class:`int`] + The IDs of the SKUs that the user subscribed to. + entitlement_ids: List[:class:`int`] + The IDs of the entitlements granted for this subscription. + current_period_start: :class:`datetime.datetime` + When the current billing period started. + current_period_end: :class:`datetime.datetime` + When the current billing period ends. + status: :class:`SubscriptionStatus` + The status of the subscription. + canceled_at: Optional[:class:`datetime.datetime`] + When the subscription was canceled. + This is only available for subscriptions with a :attr:`status` of :attr:`SubscriptionStatus.inactive`. + """ + + __slots__ = ( + '_state', + 'id', + 'user_id', + 'sku_ids', + 'entitlement_ids', + 'current_period_start', + 'current_period_end', + 'status', + 'canceled_at', + ) + + def __init__(self, *, state: ConnectionState, data: SubscriptionPayload): + self._state = 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.current_period_start: datetime.datetime = utils.parse_time(data['current_period_start']) + self.current_period_end: datetime.datetime = utils.parse_time(data['current_period_end']) + self.status: SubscriptionStatus = try_enum(SubscriptionStatus, data['status']) + self.canceled_at: Optional[datetime.datetime] = utils.parse_time(data['canceled_at']) + + def __repr__(self) -> str: + return f'' + + @property + def created_at(self) -> datetime.datetime: + """:class:`datetime.datetime`: Returns the subscription's creation time in UTC.""" + return utils.snowflake_time(self.id) + + @property + def user(self) -> Optional[User]: + """Optional[:class:`User`]: The user that is subscribed.""" + return self._state.get_user(self.user_id) diff --git a/discord/types/gateway.py b/discord/types/gateway.py index 974ceb204..6261c70dd 100644 --- a/discord/types/gateway.py +++ b/discord/types/gateway.py @@ -46,6 +46,7 @@ from .threads import Thread, ThreadMember from .scheduled_event import GuildScheduledEvent from .audit_log import AuditLogEntry from .soundboard import SoundboardSound +from .subscription import Subscription class SessionStartLimit(TypedDict): @@ -372,3 +373,6 @@ class PollVoteActionEvent(TypedDict): message_id: Snowflake guild_id: NotRequired[Snowflake] answer_id: int + + +SubscriptionCreateEvent = SubscriptionUpdateEvent = SubscriptionDeleteEvent = Subscription diff --git a/discord/types/subscription.py b/discord/types/subscription.py new file mode 100644 index 000000000..bb707afce --- /dev/null +++ b/discord/types/subscription.py @@ -0,0 +1,42 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import List, Literal, Optional, TypedDict + +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] + current_period_start: str + current_period_end: str + status: SubscriptionStatus + canceled_at: Optional[str] diff --git a/docs/api.rst b/docs/api.rst index c4feaa246..4b88e4871 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1356,6 +1356,37 @@ Stages :param after: The stage instance after the update. :type after: :class:`StageInstance` + +Subscriptions +~~~~~~~~~~~~~ + +.. function:: on_subscription_create(subscription) + + Called when a subscription is created. + + .. versionadded:: 2.5 + + :param subscription: The subscription that was created. + :type subscription: :class:`Subscription` + +.. function:: on_subscription_update(subscription) + + Called when a subscription is updated. + + .. versionadded:: 2.5 + + :param subscription: The subscription that was updated. + :type subscription: :class:`Subscription` + +.. function:: on_subscription_delete(subscription) + + Called when a subscription is deleted. + + .. versionadded:: 2.5 + + :param subscription: The subscription that was deleted. + :type subscription: :class:`Subscription` + Threads ~~~~~~~~ @@ -3754,6 +3785,25 @@ of :class:`enum.Enum`. The standard animation. +.. class:: SubscriptionStatus + + Represents the status of an subscription. + + .. versionadded:: 2.5 + + .. attribute:: active + + The subscription is active. + + .. attribute:: ending + + The subscription is active but will not renew. + + .. attribute:: inactive + + The subscription is inactive and not being charged. + + .. _discord-api-audit-logs: Audit Log Data @@ -5151,6 +5201,14 @@ Entitlement .. autoclass:: Entitlement() :members: +Subscription +~~~~~~~~~~~~ + +.. attributetable:: Subscription + +.. autoclass:: Subscription() + :members: + RawMessageDeleteEvent ~~~~~~~~~~~~~~~~~~~~~~~