Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
198819b
feat: add `InteractionResponse.send_premium_required`
shiftinv Sep 26, 2023
4a46cfe
feat: add SKU models
shiftinv Sep 27, 2023
f4f78e7
feat: add `Client.skus()`
shiftinv Sep 27, 2023
0b29e59
feat: add entitlement models
shiftinv Sep 27, 2023
7b2f4d0
feat: add `Client.entitlements()` iterator
shiftinv Sep 28, 2023
c133a6d
docs: documentation improvements/crosslinks
shiftinv Sep 28, 2023
07c4311
feat: add `Interaction.entitlements`
shiftinv Sep 28, 2023
c774ab3
feat: add gateway events
shiftinv Sep 28, 2023
30180af
feat: create/delete test entitlements
shiftinv Sep 28, 2023
3ef291b
docs: add changelog entry
shiftinv Sep 28, 2023
e385c52
Merge remote-tracking branch 'upstream/master' into feature/app-subsc…
shiftinv Oct 7, 2023
8c7eb23
feat: add `SKUFlags.available`
shiftinv Oct 7, 2023
502e1ad
feat: add `Entitlement.deleted`
shiftinv Oct 7, 2023
0fde8ae
Merge remote-tracking branch 'upstream/master' into feature/app-subsc…
shiftinv Oct 19, 2023
8cb2be8
chore: resolve some TODOs, rename `create_test_entitlement` to `creat…
shiftinv Oct 20, 2023
819d21a
docs: clarify entitlement_update event description
shiftinv Oct 20, 2023
849bddc
fix: use correct owner_type
shiftinv Oct 20, 2023
a1e31ae
fix: fix `EntitlementIterator` order, add `oldest_first` parameter
shiftinv Oct 20, 2023
b027af7
docs: clarify event docs, mention test entitlements
shiftinv Oct 20, 2023
1269e55
nit: fix owner_type comment, improve `interaction.entitlements` type
shiftinv Oct 23, 2023
786cc78
docs: add versionadded to `send_premium_required`
shiftinv Oct 23, 2023
3e6c80c
fix: rename response to `.require_premium()`, which fits better with …
shiftinv Oct 23, 2023
989327a
docs: add premium response example
shiftinv Oct 23, 2023
2ba777e
Merge remote-tracking branch 'upstream/master' into feature/app-subsc…
shiftinv Oct 29, 2023
4b66b52
revert: remove sku/entitlement http methods, endpoints scheduled for …
shiftinv Oct 29, 2023
de5fee7
Merge branch 'master' into feature/app-subscriptions
shiftinv Nov 22, 2023
b07ded3
unrevert: "revert: remove sku/entitlement http methods, endpoints sch…
shiftinv Dec 6, 2023
30d2dd1
nit: add "."
shiftinv Dec 6, 2023
e5eab79
feat: add `created_at` to entitlement and sku objects
shiftinv Jan 3, 2024
3b7efcc
fix(docs): use new collapsible element for operations
shiftinv Jan 3, 2024
c9f3b66
chore(docs): remove unnecessary versionadded
shiftinv Jan 3, 2024
6000a95
nit: space
shiftinv Jan 3, 2024
491b2b3
docs: link to apidocs on sku page
shiftinv Jan 3, 2024
2aab8b4
fix(docs): this is why you shouldn't just blindly copy-paste code bet…
shiftinv Jan 3, 2024
bac6567
Merge branch 'master' into feature/app-subscriptions
shiftinv Jan 3, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions changelog/1113.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Support application subscriptions (see the :ddocs:`official docs <monetization/overview>` for more info).
- New types: :class:`SKU`, :class:`Entitlement`.
- 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 :class:`Client` methods: :meth:`~Client.skus`, :meth:`~Client.entitlements`, :meth:`~Client.create_entitlement`.
2 changes: 2 additions & 0 deletions disnake/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from .custom_warnings import *
from .embeds import *
from .emoji import *
from .entitlement import *
from .enums import *
from .errors import *
from .file import *
Expand All @@ -60,6 +61,7 @@
from .reaction import *
from .role import *
from .shard import *
from .sku import *
from .stage_instance import *
from .sticker import *
from .team import *
Expand Down
136 changes: 134 additions & 2 deletions disnake/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@

import aiohttp

from . import utils
from . import abc, utils
from .activity import ActivityTypes, BaseActivity, create_activity
from .app_commands import (
APIMessageCommand,
Expand All @@ -47,6 +47,7 @@
from .backoff import ExponentialBackoff
from .channel import PartialMessageable, _threaded_channel_factory
from .emoji import Emoji
from .entitlement import Entitlement
from .enums import ApplicationCommandType, ChannelType, Event, Status
from .errors import (
ConnectionClosed,
Expand All @@ -63,9 +64,10 @@
from .http import HTTPClient
from .i18n import LocalizationProtocol, LocalizationStore
from .invite import Invite
from .iterators import GuildIterator
from .iterators import EntitlementIterator, GuildIterator
from .mentions import AllowedMentions
from .object import Object
from .sku import SKU
from .stage_instance import StageInstance
from .state import ConnectionState
from .sticker import GuildSticker, StandardSticker, StickerPack, _sticker_factory
Expand Down Expand Up @@ -2989,3 +2991,133 @@ async def edit_role_connection_metadata(
self.application_id, payload
)
return [ApplicationRoleConnectionMetadata._from_data(record) for record in data]

async def skus(self) -> List[SKU]:
"""|coro|

Retrieves the :class:`.SKU`\\s for the application.

To manage application subscription entitlements, you should use the SKU
with :attr:`.SKUType.subscription`.

.. versionadded:: 2.10

Raises
------
HTTPException
Retrieving the SKUs failed.

Returns
-------
List[:class:`.SKU`]
The list of SKUs.
"""
data = await self.http.get_skus(self.application_id)
return [SKU(data=d) for d in data]

def entitlements(
self,
*,
limit: Optional[int] = 100,
before: Optional[SnowflakeTime] = None,
after: Optional[SnowflakeTime] = None,
user: Optional[Snowflake] = None,
guild: Optional[Snowflake] = None,
skus: Optional[Sequence[Snowflake]] = None,
exclude_ended: bool = False,
oldest_first: bool = False,
) -> EntitlementIterator:
"""Retrieves an :class:`.AsyncIterator` that enables receiving entitlements for the application.

.. note::

This method is an API call. To get the entitlements of the invoking user/guild
in interactions, consider using :attr:`.Interaction.entitlements`.

Entries are returned in order from newest to oldest by default;
pass ``oldest_first=True`` to reverse the iteration order.

All parameters are optional.

.. versionadded:: 2.10

Parameters
----------
limit: Optional[:class:`int`]
The number of entitlements to retrieve.
If ``None``, retrieves every entitlement.
Note, however, that this would make it a slow operation.
Defaults to ``100``.
before: Union[:class:`.abc.Snowflake`, :class:`datetime.datetime`]
Retrieves entitlements 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 entitlements 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.
user: Optional[:class:`.abc.Snowflake`]
The user to retrieve entitlements for.
guild: Optional[:class:`.abc.Snowflake`]
The guild to retrieve entitlements for.
skus: Optional[Sequence[:class:`.abc.Snowflake`]]
The SKUs for which entitlements are retrieved.
exclude_ended: :class:`bool`
Whether to exclude ended/expired entitlements. Defaults to ``False``.
oldest_first: :class:`bool`
If set to ``True``, return entries in oldest->newest order. Defaults to ``False``.

Raises
------
HTTPException
Retrieving the entitlements failed.

Yields
------
:class:`.Entitlement`
The entitlements for the given parameters.
"""
return EntitlementIterator(
self.application_id,
state=self._connection,
limit=limit,
before=before,
after=after,
user_id=user.id if user is not None else None,
guild_id=guild.id if guild is not None else None,
sku_ids=[sku.id for sku in skus] if skus else None,
exclude_ended=exclude_ended,
oldest_first=oldest_first,
)

async def create_entitlement(
self, sku: Snowflake, owner: Union[abc.User, Guild]
) -> Entitlement:
"""|coro|

Creates a new test :class:`.Entitlement` for the given user or guild, with no expiry.

Parameters
----------
sku: :class:`.abc.Snowflake`
The :class:`.SKU` to grant the entitlement for.
owner: Union[:class:`.abc.User`, :class:`.Guild`]
The user or guild to grant the entitlement to.

Raises
------
HTTPException
Creating the entitlement failed.

Returns
-------
:class:`.Entitlement`
The newly created entitlement.
"""
data = await self.http.create_test_entitlement(
self.application_id,
sku_id=sku.id,
owner_id=owner.id,
owner_type=2 if isinstance(owner, abc.User) else 1,
)
return Entitlement(data=data, state=self._connection)
173 changes: 173 additions & 0 deletions disnake/entitlement.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
# SPDX-License-Identifier: MIT

from __future__ import annotations

import datetime
from typing import TYPE_CHECKING, Optional

from .enums import EntitlementType, try_enum
from .mixins import Hashable
from .utils import _get_as_snowflake, parse_time, snowflake_time, utcnow

if TYPE_CHECKING:
from .guild import Guild
from .state import ConnectionState
from .types.entitlement import Entitlement as EntitlementPayload
from .user import User


__all__ = ("Entitlement",)


class Entitlement(Hashable):
"""Represents an entitlement.

This is usually retrieved using :meth:`Client.entitlements`, from
:attr:`Interaction.entitlements` when using interactions, or provided by
events (e.g. :func:`on_entitlement_create`).

Note that some entitlements may have ended already; consider using
:meth:`is_active` to check whether a given entitlement is considered active at the current time,
or use ``exclude_ended=True`` when fetching entitlements using :meth:`Client.entitlements`.

You may create new entitlements for testing purposes using :meth:`Client.create_entitlement`.

.. collapse:: operations

.. describe:: x == y

Checks if two :class:`Entitlement`\\s are equal.

.. describe:: x != y

Checks if two :class:`Entitlement`\\s are not equal.

.. describe:: hash(x)

Returns the entitlement's hash.

.. versionadded:: 2.10

Attributes
----------
id: :class:`int`
The entitlement's ID.
type: :class:`EntitlementType`
The entitlement's type.
sku_id: :class:`int`
The ID of the associated SKU.
user_id: Optional[:class:`int`]
The ID of the user that is granted access to the entitlement's SKU.

See also :attr:`user`.
guild_id: Optional[:class:`int`]
The ID of the guild that is granted access to the entitlement's SKU.

See also :attr:`guild`.
application_id: :class:`int`
The parent application's ID.
deleted: :class:`bool`
Whether the entitlement was deleted.
starts_at: Optional[:class:`datetime.datetime`]
The time at which the entitlement starts being active.
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.
"""

__slots__ = (
"_state",
"id",
"sku_id",
"user_id",
"guild_id",
"application_id",
"type",
"deleted",
"starts_at",
"ends_at",
)

def __init__(self, *, data: EntitlementPayload, state: ConnectionState) -> None:
self._state: ConnectionState = state

self.id: int = int(data["id"])
self.sku_id: int = int(data["sku_id"])
self.user_id: Optional[int] = _get_as_snowflake(data, "user_id")
self.guild_id: Optional[int] = _get_as_snowflake(data, "guild_id")
self.application_id: int = int(data["application_id"])
self.type: EntitlementType = try_enum(EntitlementType, data["type"])
self.deleted: bool = data.get("deleted", False)
self.starts_at: Optional[datetime.datetime] = parse_time(data.get("starts_at"))
self.ends_at: Optional[datetime.datetime] = parse_time(data.get("ends_at"))

def __repr__(self) -> str:
# presumably one of these is set
if self.user_id:
grant_repr = f"user_id={self.user_id!r}"
else:
grant_repr = f"guild_id={self.guild_id!r}"
return (
f"<Entitlement id={self.id!r} sku_id={self.sku_id!r} type={self.type!r} {grant_repr}>"
)

@property
def created_at(self) -> datetime.datetime:
""":class:`datetime.datetime`: Returns the entitlement's creation time in UTC."""
return snowflake_time(self.id)

@property
def guild(self) -> Optional[Guild]:
"""Optional[:class:`Guild`]: The guild that is granted access to
this entitlement's SKU, if applicable.
"""
return self._state._get_guild(self.guild_id)

@property
def user(self) -> Optional[User]:
"""Optional[:class:`User`]: The user that is granted access to
this entitlement's SKU, if applicable.

Requires the user to be cached.
See also :attr:`user_id`.
"""
return self._state.get_user(self.user_id)

def is_active(self) -> bool:
"""Whether the entitlement is currently active,
based on :attr:`starts_at` and :attr:`ends_at`.

Always returns ``True`` for test entitlements.

:return type: :class:`bool`
"""
if self.deleted:
return False

now = utcnow()
if self.starts_at is not None and now < self.starts_at:
return False
if self.ends_at is not None and now >= self.ends_at:
return False

return True

async def delete(self) -> None:
"""|coro|

Deletes the entitlement.

This is only valid for test entitlements; you cannot use this to
delete entitlements that users purchased.

Raises
------
NotFound
The entitlement does not exist.
HTTPException
Deleting the entitlement failed.
"""
await self._state.http.delete_test_entitlement(self.application_id, self.id)
Loading