diff --git a/docs/src/API Reference/API Reference/models/Discord/entitlement.md b/docs/src/API Reference/API Reference/models/Discord/entitlement.md new file mode 100644 index 000000000..8211c542a --- /dev/null +++ b/docs/src/API Reference/API Reference/models/Discord/entitlement.md @@ -0,0 +1 @@ +::: interactions.models.discord.entitlement diff --git a/docs/src/API Reference/API Reference/models/Discord/index.md b/docs/src/API Reference/API Reference/models/Discord/index.md index d56908be7..1b45617cd 100644 --- a/docs/src/API Reference/API Reference/models/Discord/index.md +++ b/docs/src/API Reference/API Reference/models/Discord/index.md @@ -14,6 +14,7 @@ search: - [Components](components) - [Embed](embed) - [Emoji](emoji) +- [Entitlement](entitlement) - [Enums](enums) - [File](file) - [Guild](guild) diff --git a/interactions/__init__.py b/interactions/__init__.py index bb9b70b2a..50cdda95c 100644 --- a/interactions/__init__.py +++ b/interactions/__init__.py @@ -129,6 +129,7 @@ EmbedField, EmbedFooter, EmbedProvider, + Entitlement, ExplicitContentFilterLevel, Extension, File, @@ -455,6 +456,7 @@ "EmbedField", "EmbedFooter", "EmbedProvider", + "Entitlement", "errors", "events", "ExplicitContentFilterLevel", diff --git a/interactions/api/events/__init__.py b/interactions/api/events/__init__.py index f8d1789a2..cccf69857 100644 --- a/interactions/api/events/__init__.py +++ b/interactions/api/events/__init__.py @@ -12,6 +12,9 @@ ChannelDelete, ChannelPinsUpdate, ChannelUpdate, + EntitlementCreate, + EntitlementDelete, + EntitlementUpdate, GuildAuditLogEntryCreate, GuildAvailable, GuildEmojisUpdate, @@ -120,6 +123,9 @@ "ComponentError", "Connect", "Disconnect", + "EntitlementCreate", + "EntitlementDelete", + "EntitlementUpdate", "Error", "ExtensionCommandParse", "ExtensionLoad", diff --git a/interactions/api/events/discord.py b/interactions/api/events/discord.py index c621fd01f..182c5a467 100644 --- a/interactions/api/events/discord.py +++ b/interactions/api/events/discord.py @@ -43,6 +43,9 @@ async def an_event_handler(event: ChannelCreate): "ChannelDelete", "ChannelPinsUpdate", "ChannelUpdate", + "EntitlementCreate", + "EntitlementDelete", + "EntitlementUpdate", "GuildAuditLogEntryCreate", "GuildEmojisUpdate", "GuildJoin", @@ -108,6 +111,7 @@ async def an_event_handler(event: ChannelCreate): VoiceChannel, ) from interactions.models.discord.emoji import CustomEmoji, PartialEmoji + from interactions.models.discord.entitlement import Entitlement from interactions.models.discord.guild import Guild, GuildIntegration from interactions.models.discord.message import Message from interactions.models.discord.reaction import Reaction @@ -821,3 +825,28 @@ def member(self) -> Optional["Member"]: @attrs.define(eq=False, order=False, hash=False, kw_only=False) class GuildScheduledEventUserRemove(GuildScheduledEventUserAdd): """Dispatched when scheduled event is removed""" + + +@attrs.define(eq=False, order=False, hash=False, kw_only=False) +class BaseEntitlementEvent(BaseEvent): + entitlement: "Entitlement" = attrs.field(repr=True) + + +@attrs.define(eq=False, order=False, hash=False, kw_only=False) +class EntitlementCreate(BaseEntitlementEvent): + """Dispatched when a user subscribes to a SKU.""" + + +@attrs.define(eq=False, order=False, hash=False, kw_only=False) +class EntitlementUpdate(BaseEntitlementEvent): + """Dispatched when a user's subscription renews for the next billing period.""" + + +@attrs.define(eq=False, order=False, hash=False, kw_only=False) +class EntitlementDelete(BaseEntitlementEvent): + """ + Dispatched when a user's entitlement is deleted. + + Notably, this event is not dispatched when a user's subscription is cancelled. + Instead, you simply won't receive an EntitlementUpdate event for the next billing period. + """ diff --git a/interactions/api/events/processors/__init__.py b/interactions/api/events/processors/__init__.py index 750d145a4..65e0841d5 100644 --- a/interactions/api/events/processors/__init__.py +++ b/interactions/api/events/processors/__init__.py @@ -12,6 +12,7 @@ from .voice_events import VoiceEvents from ._template import Processor from .auto_mod import AutoModEvents +from .entitlement_events import EntitlementEvents __all__ = ( "ChannelEvents", @@ -28,4 +29,5 @@ "VoiceEvents", "Processor", "AutoModEvents", + "EntitlementEvents", ) diff --git a/interactions/api/events/processors/entitlement_events.py b/interactions/api/events/processors/entitlement_events.py new file mode 100644 index 000000000..ed806b530 --- /dev/null +++ b/interactions/api/events/processors/entitlement_events.py @@ -0,0 +1,22 @@ +from typing import TYPE_CHECKING + +from interactions.models.discord.entitlement import Entitlement +import interactions.api.events as events +from ._template import EventMixinTemplate, Processor + +if TYPE_CHECKING: + from interactions.api.events import RawGatewayEvent + + +class EntitlementEvents(EventMixinTemplate): + @Processor.define() + async def _on_raw_entitlement_create(self, event: "RawGatewayEvent") -> None: + self.dispatch(events.EntitlementCreate(Entitlement.from_dict(event.data, self))) + + @Processor.define() + async def _on_raw_entitlement_update(self, event: "RawGatewayEvent") -> None: + self.dispatch(events.EntitlementUpdate(Entitlement.from_dict(event.data, self))) + + @Processor.define() + async def _on_raw_entitlement_delete(self, event: "RawGatewayEvent") -> None: + self.dispatch(events.EntitlementDelete(Entitlement.from_dict(event.data, self))) diff --git a/interactions/api/http/http_client.py b/interactions/api/http/http_client.py index ac8578348..fd98caa86 100644 --- a/interactions/api/http/http_client.py +++ b/interactions/api/http/http_client.py @@ -19,6 +19,7 @@ BotRequests, ChannelRequests, EmojiRequests, + EntitlementRequests, GuildRequests, InteractionRequests, MemberRequests, @@ -204,6 +205,7 @@ class HTTPClient( BotRequests, ChannelRequests, EmojiRequests, + EntitlementRequests, GuildRequests, InteractionRequests, MemberRequests, diff --git a/interactions/api/http/http_requests/__init__.py b/interactions/api/http/http_requests/__init__.py index 8d6f72840..0e642aa02 100644 --- a/interactions/api/http/http_requests/__init__.py +++ b/interactions/api/http/http_requests/__init__.py @@ -1,6 +1,7 @@ from .bot import BotRequests from .channels import ChannelRequests from .emojis import EmojiRequests +from .entitlements import EntitlementRequests from .guild import GuildRequests from .interactions import InteractionRequests from .members import MemberRequests @@ -16,6 +17,7 @@ "BotRequests", "ChannelRequests", "EmojiRequests", + "EntitlementRequests", "GuildRequests", "InteractionRequests", "MemberRequests", diff --git a/interactions/api/http/http_requests/entitlements.py b/interactions/api/http/http_requests/entitlements.py new file mode 100644 index 000000000..8ab4e15aa --- /dev/null +++ b/interactions/api/http/http_requests/entitlements.py @@ -0,0 +1,88 @@ +from typing import TYPE_CHECKING, Optional + +from ..route import Route, PAYLOAD_TYPE +from interactions.models.internal.protocols import CanRequest +from interactions.models.discord.snowflake import to_optional_snowflake, to_snowflake +from interactions.client.utils.serializer import dict_filter_none + +if TYPE_CHECKING: + from interactions.models.discord.snowflake import Snowflake_Type + +__all__ = ("EntitlementRequests",) + + +class EntitlementRequests(CanRequest): + async def get_entitlements( + self, + application_id: "Snowflake_Type", + *, + user_id: "Optional[Snowflake_Type]" = None, + sku_ids: "Optional[list[Snowflake_Type]]" = None, + before: "Optional[Snowflake_Type]" = None, + after: "Optional[Snowflake_Type]" = None, + limit: Optional[int] = 100, + guild_id: "Optional[Snowflake_Type]" = None, + exclude_ended: Optional[bool] = None, + ) -> list[dict]: + """ + Get an application's entitlements. + + Args: + application_id: The ID of the application. + user_id: The ID of the user to filter entitlements by. + sku_ids: The IDs of the SKUs to filter entitlements by. + before: Get entitlements before this ID. + after: Get entitlements after this ID. + limit: The maximum number of entitlements to return. Maximum is 100. + guild_id: The ID of the guild to filter entitlements by. + exclude_ended: Whether to exclude ended entitlements. + + Returns: + A dictionary containing the application's entitlements. + """ + params: PAYLOAD_TYPE = { + "user_id": to_optional_snowflake(user_id), + "sku_ids": [to_snowflake(sku_id) for sku_id in sku_ids] if sku_ids else None, + "before": to_optional_snowflake(before), + "after": to_optional_snowflake(after), + "limit": limit, + "guild_id": to_optional_snowflake(guild_id), + "exclude_ended": exclude_ended, + } + params = dict_filter_none(params) + + return await self.request( + Route("GET", "/applications/{application_id}/entitlements", application_id=application_id), params=params + ) + + async def create_test_entitlement(self, payload: dict, application_id: "Snowflake_Type") -> dict: + """ + Create a test entitlement for an application. + + Args: + payload: The entitlement's data. + application_id: The ID of the application. + + Returns: + A dictionary containing the test entitlement. + """ + return await self.request( + Route("POST", "/applications/{application_id}/entitlements", application_id=application_id), payload=payload + ) + + async def delete_test_entitlement(self, application_id: "Snowflake_Type", entitlement_id: "Snowflake_Type") -> None: + """ + Delete a test entitlement for an application. + + Args: + application_id: The ID of the application. + entitlement_id: The ID of the entitlement. + """ + await self.request( + Route( + "DELETE", + "/applications/{application_id}/entitlements/{entitlement_id}", + application_id=application_id, + entitlement_id=entitlement_id, + ) + ) diff --git a/interactions/client/client.py b/interactions/client/client.py index 2d4da248f..729998bbd 100644 --- a/interactions/client/client.py +++ b/interactions/client/client.py @@ -87,6 +87,7 @@ from interactions.models.discord.color import BrandColors from interactions.models.discord.components import get_components_ids, BaseComponent from interactions.models.discord.embed import Embed +from interactions.models.discord.entitlement import Entitlement from interactions.models.discord.enums import ( ComponentType, Intents, @@ -213,6 +214,7 @@ class Client( processors.AutoModEvents, processors.ChannelEvents, + processors.EntitlementEvents, processors.GuildEvents, processors.IntegrationEvents, processors.MemberEvents, @@ -2464,6 +2466,72 @@ def get_bot_voice_state(self, guild_id: "Snowflake_Type") -> Optional[ActiveVoic """ return self._connection_state.get_voice_state(guild_id) + async def fetch_entitlements( + self, + *, + user_id: "Optional[Snowflake_Type]" = None, + sku_ids: "Optional[list[Snowflake_Type]]" = None, + before: "Optional[Snowflake_Type]" = None, + after: "Optional[Snowflake_Type]" = None, + limit: Optional[int] = 100, + guild_id: "Optional[Snowflake_Type]" = None, + exclude_ended: Optional[bool] = None, + ) -> List[Entitlement]: + """ + Fetch the entitlements for the bot's application. + + Args: + user_id: The ID of the user to filter entitlements by. + sku_ids: The IDs of the SKUs to filter entitlements by. + before: Get entitlements before this ID. + after: Get entitlements after this ID. + limit: The maximum number of entitlements to return. Maximum is 100. + guild_id: The ID of the guild to filter entitlements by. + exclude_ended: Whether to exclude ended entitlements. + + Returns: + A list of entitlements. + """ + entitlements_data = await self.http.get_entitlements( + self.app.id, + user_id=user_id, + sku_ids=sku_ids, + before=before, + after=after, + limit=limit, + guild_id=guild_id, + exclude_ended=exclude_ended, + ) + return Entitlement.from_list(entitlements_data, self) + + async def create_test_entitlement( + self, sku_id: "Snowflake_Type", owner_id: "Snowflake_Type", owner_type: int + ) -> Entitlement: + """ + Create a test entitlement for the bot's application. + + Args: + sku_id: The ID of the SKU to create the entitlement for. + owner_id: The ID of the owner of the entitlement. + owner_type: The type of the owner of the entitlement. 1 for a guild subscription, 2 for a user subscription + + Returns: + The created entitlement. + """ + payload = {"sku_id": to_snowflake(sku_id), "owner_id": to_snowflake(owner_id), "owner_type": owner_type} + + entitlement_data = await self.http.create_test_entitlement(payload, self.app.id) + return Entitlement.from_dict(entitlement_data, self) + + async def delete_test_entitlement(self, entitlement_id: "Snowflake_Type") -> None: + """ + Delete a test entitlement for the bot's application. + + Args: + entitlement_id: The ID of the entitlement to delete. + """ + await self.http.delete_test_entitlement(self.app.id, to_snowflake(entitlement_id)) + def mention_command(self, name: str, scope: int = 0) -> str: """ Returns a string that would mention the interaction specified. diff --git a/interactions/models/__init__.py b/interactions/models/__init__.py index 41ccfc7e0..e66a5a5aa 100644 --- a/interactions/models/__init__.py +++ b/interactions/models/__init__.py @@ -54,6 +54,7 @@ EmbedField, EmbedFooter, EmbedProvider, + Entitlement, ExplicitContentFilterLevel, File, FlatUIColors, @@ -396,6 +397,7 @@ "EmbedField", "EmbedFooter", "EmbedProvider", + "Entitlement", "ExplicitContentFilterLevel", "Extension", "File", diff --git a/interactions/models/discord/__init__.py b/interactions/models/discord/__init__.py index 035932e62..2168dd0c9 100644 --- a/interactions/models/discord/__init__.py +++ b/interactions/models/discord/__init__.py @@ -71,6 +71,7 @@ from .embed import Embed, EmbedAttachment, EmbedAuthor, EmbedField, EmbedFooter, EmbedProvider, process_embeds from .emoji import CustomEmoji, PartialEmoji, process_emoji, process_emoji_req_format +from .entitlement import Entitlement from .enums import ( ActivityFlag, ActivityType, @@ -223,6 +224,7 @@ "EmbedField", "EmbedFooter", "EmbedProvider", + "Entitlement", "ExplicitContentFilterLevel", "File", "FlatUIColors", diff --git a/interactions/models/discord/entitlement.py b/interactions/models/discord/entitlement.py new file mode 100644 index 000000000..bb14ba52f --- /dev/null +++ b/interactions/models/discord/entitlement.py @@ -0,0 +1,48 @@ +from typing import Optional, TYPE_CHECKING + +import attrs + +from interactions.models.discord.timestamp import Timestamp +from interactions.models.discord.enums import EntitlementType +from interactions.models.discord.base import DiscordObject +from interactions.client.utils.attr_converters import optional as optional_c +from interactions.client.utils.attr_converters import timestamp_converter +from interactions.models.discord.snowflake import to_snowflake, to_optional_snowflake, Snowflake_Type + +if TYPE_CHECKING: + from interactions.models.discord.guild import Guild + from interactions.models.discord.user import User + +__all__ = ("Entitlement",) + + +@attrs.define(eq=False, order=False, hash=False, kw_only=False) +class Entitlement(DiscordObject): + sku_id: Snowflake_Type = attrs.field(repr=False, converter=to_snowflake) + """ID of the SKU.""" + application_id: Snowflake_Type = attrs.field(repr=False, converter=to_snowflake) + """ID of the parent application.""" + type: EntitlementType = attrs.field(repr=False, converter=EntitlementType) + """The type of entitlement.""" + deleted: bool = attrs.field(repr=False, converter=bool) + """Whether the entitlement is deleted.""" + subscription_id: Optional[Snowflake_Type] = attrs.field(repr=False, converter=to_optional_snowflake, default=None) + """The ID of the subscription plan. Not present when using test entitlements.""" + starts_at: Optional[Timestamp] = attrs.field(repr=False, converter=optional_c(timestamp_converter), default=None) + """Start date at which the entitlement is valid. Not present when using test entitlements.""" + ends_at: Optional[Timestamp] = attrs.field(repr=False, converter=optional_c(timestamp_converter), default=None) + """Date at which the entitlement is no longer valid. Not present when using test entitlements.""" + _user_id: Optional[Snowflake_Type] = attrs.field(repr=False, converter=to_optional_snowflake, default=None) + """The ID of the user that is granted access to the entitlement's SKU.""" + _guild_id: Optional[Snowflake_Type] = attrs.field(repr=False, converter=to_optional_snowflake, default=None) + """The ID of the guild that is granted access to the entitlement's SKU.""" + + @property + def user(self) -> "Optional[User]": + """The user that is granted access to the entitlement's SKU, if applicable.""" + return self.client.cache.get_user(self._user_id) + + @property + def guild(self) -> "Optional[Guild]": + """The guild that is granted access to the entitlement's SKU, if applicable.""" + return self.client.cache.get_guild(self._guild_id) diff --git a/interactions/models/discord/enums.py b/interactions/models/discord/enums.py index a89337ac0..4d247f5ef 100644 --- a/interactions/models/discord/enums.py +++ b/interactions/models/discord/enums.py @@ -1065,3 +1065,9 @@ class ForumSortOrder(CursedIntEnum): @classmethod def converter(cls, value: Optional[int]) -> "ForumSortOrder": return None if value is None else cls(value) + + +class EntitlementType(CursedIntEnum): + """The type of entitlement.""" + + APPLICATION_SUBSCRIPTION = 8 diff --git a/interactions/models/internal/context.py b/interactions/models/internal/context.py index 489e20866..616be2ccb 100644 --- a/interactions/models/internal/context.py +++ b/interactions/models/internal/context.py @@ -19,6 +19,7 @@ from interactions.client.errors import HTTPException, AlreadyDeferred, AlreadyResponded from interactions.client.mixins.send import SendMixin +from interactions.models.discord.entitlement import Entitlement from interactions.models.discord.enums import ( Permissions, MessageFlags, @@ -252,6 +253,9 @@ class BaseInteractionContext(BaseContext): ephemeral: bool """Whether the interaction response is ephemeral.""" + entitlements: list[Entitlement] + """The entitlements of the invoking user.""" + _context_type: int """The context type of the interaction.""" command_id: Snowflake @@ -282,6 +286,7 @@ def from_dict(cls, client: "interactions.Client", payload: dict) -> Self: instance.guild_locale = payload.get("guild_locale", instance.locale) instance._context_type = payload.get("type", 0) instance.resolved = Resolved.from_dict(client, payload["data"].get("resolved", {}), payload.get("guild_id")) + instance.entitlements = Entitlement.from_list(payload["entitlements"], client) instance.channel_id = Snowflake(payload["channel_id"]) if channel := payload.get("channel"): @@ -418,6 +423,19 @@ async def defer(self, *, ephemeral: bool = False) -> None: self.deferred = True self.ephemeral = ephemeral + async def send_premium_required(self) -> None: + """ + Send a premium required response. + + When used, the user will be prompted to subscribe to premium to use this feature. + Only available for applications with monetization enabled. + """ + if self.responded: + raise RuntimeError("Cannot send a premium required response after responding") + + await self.client.http.post_initial_response({"type": 10}, self.id, self.token) + self.responded = True + async def _send_http_request( self, message_payload: dict, files: typing.Iterable["UPLOADABLE_TYPE"] | None = None ) -> dict: