diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2d37275c..5f7d9ae9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,6 @@ repos: entry: isort language: python types: [python] - - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.3.0 hooks: diff --git a/docs/api/enums.rst b/docs/api/enums.rst index b3b563e0..ef07a7d2 100644 --- a/docs/api/enums.rst +++ b/docs/api/enums.rst @@ -37,3 +37,5 @@ Enums .. autoclass:: VerificationLevel .. autoclass:: Status .. autoclass:: ActivityType +.. autoclass:: SKUType +.. autoclass:: EntitlementType diff --git a/docs/api/flags.rst b/docs/api/flags.rst index 96e28ea8..2ce5691b 100644 --- a/docs/api/flags.rst +++ b/docs/api/flags.rst @@ -216,3 +216,9 @@ Flags .. py:attribute:: auto_moderation_block_message .. py:attribute:: auto_moderation_flag_to_channel .. py:attribute:: auto_moderation_user_communication_disabled + +.. class:: SKUFlags + + .. py:attribute:: available + .. py:attribute:: guild_subscription + .. py:attribute:: user_subscription diff --git a/docs/api/models.rst b/docs/api/models.rst index 41bf560b..f252eea0 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -293,3 +293,11 @@ Models that relate to interactions. :inherited-members: :no-special-members: .. .. autoclass:: InteractionWebhook +.. autoclass:: Entitlement + :members: + :inherited-members: + :no-special-members: +.. autoclass:: SKU + :members: + :inherited-members: + :no-special-members: diff --git a/novus/__init__.py b/novus/__init__.py index e43a1630..b829d3f9 100644 --- a/novus/__init__.py +++ b/novus/__init__.py @@ -63,6 +63,8 @@ 'ContextComandData', 'Embed', 'Emoji', + 'Entitlement', + 'EntitlementType', 'EventEntityType', 'EventPrivacyLevel', 'EventStatus', @@ -132,6 +134,9 @@ 'Reaction', 'Role', 'RoleSelectMenu', + 'SKU', + 'SKUFlags', + 'SKUType', 'ScheduledEvent', 'SelectOption', 'StageInstance', diff --git a/novus/api/_cache.py b/novus/api/_cache.py index 26cca597..e50d3d1a 100644 --- a/novus/api/_cache.py +++ b/novus/api/_cache.py @@ -83,6 +83,12 @@ def __repr__(self) -> str: def do_nothing(instance: Any, *items: Any) -> None: pass + @property + def application_id(self) -> int | None: + if self.application: + return self.application.id + return None + def add_guilds(self, *items: Guild) -> None: for i in items: self.guild_ids.add(i.id) diff --git a/novus/api/_http.py b/novus/api/_http.py index b41db348..4893269e 100644 --- a/novus/api/_http.py +++ b/novus/api/_http.py @@ -47,6 +47,7 @@ from .guild_template import GuildTemplateHTTPConnection from .interaction import InteractionHTTPConnection from .invite import InviteHTTPConnection +from .monetization import MonetizationHTTPConnection from .oauth2 import Oauth2HTTPConnection from .stage_instance import StageHTTPConnection from .sticker import StickerHTTPConnection @@ -127,12 +128,15 @@ class HTTPConnection: guild_template : GuildTemplateHTTPConnection interaction : InteractionHTTPConnection invite : InviteHTTPConnection + monetization : MonetizationHTTPConnection oauth2 : Oauth2HTTPConnection stage_instance : StageHTTPConnection sticker : StickerHTTPConnection user : UserHTTPConnection voice : VoiceHTTPConnection webhook : WebhookHTTPConnection + application_id : int + The ID of the associated application. """ AUTH_PREFIX: str = "Bot" @@ -167,6 +171,7 @@ def __init__( self.guild_template = GuildTemplateHTTPConnection(self) self.interaction = InteractionHTTPConnection(self) self.invite = InviteHTTPConnection(self) + self.monetization = MonetizationHTTPConnection(self) self.oauth2 = Oauth2HTTPConnection(self) self.stage_instance = StageHTTPConnection(self) self.sticker = StickerHTTPConnection(self) @@ -195,6 +200,10 @@ async def __aexit__(self, *_args: Any) -> None: if self._session: await self._session.close() + @property + def application_id(self) -> int: + return self.cache.application_id + def request_params( self, *, diff --git a/novus/api/monetization.py b/novus/api/monetization.py new file mode 100644 index 00000000..f8cc8f21 --- /dev/null +++ b/novus/api/monetization.py @@ -0,0 +1,92 @@ +""" +Copyright (c) Kae Bartlett + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation, either version 3 of the License, or +any later version. + +This program is dis2tributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ._route import Route +from ..models import Entitlement, SKU + +if TYPE_CHECKING: + from ._http import HTTPConnection + from .. import payloads + +__all__ = ( + 'MonetizationHTTPConnection', +) + + +class MonetizationHTTPConnection: + + def __init__(self, parent: HTTPConnection): + self.parent = parent + + async def list_skus( + self, + application_id: int) -> list[SKU]: + """List all SKUs associated with the given application ID.""" + + route = Route( + "GET", + "/applications/{application_id}/skus", + application_id=application_id, + ) + data: list[payloads.SKU] = await self.parent.request( + route, + ) + return [ + SKU(state=self.parent, data=d) + for d in data + ] + + async def list_entitlements( + self, + application_id: int) -> list[Entitlement]: + """List all entitlements associated with the given application ID.""" + + route = Route( + "GET", + "/applications/{application_id}/commands", + application_id=application_id, + ) + data: list[payloads.ApplicationCommand] = await self.parent.request( + route, + ) + return [ + Entitlement(state=self.parent, data=d) + for d in data + ] + + async def consume_entitlement( + self, + application_id: int, + entitlement_id: int) -> list[Entitlement]: + """List all entitlements associated with the given application ID.""" + + route = Route( + "GET", + "/applications/{application_id}/commands", + application_id=application_id, + ) + data: list[payloads.ApplicationCommand] = await self.parent.request( + route, + ) + return [ + Entitlement(state=self.parent, data=d) + for d in data + ] diff --git a/novus/enums/__init__.py b/novus/enums/__init__.py index 7a67b782..62509520 100644 --- a/novus/enums/__init__.py +++ b/novus/enums/__init__.py @@ -24,6 +24,7 @@ from .guild import * from .interaction import * from .message import * +from .monetization import * from .presence import * from .scheduled_event import * from .sticker import * @@ -43,6 +44,7 @@ 'ChannelType', 'ComponentType', 'ContentFilterLevel', + 'EntitlementType', 'EventEntityType', 'EventPrivacyLevel', 'EventStatus', @@ -57,6 +59,7 @@ 'NotificationLevel', 'PermissionOverwriteType', 'PremiumTier', + 'SKUType', 'Status', 'StickerFormat', 'StickerType', diff --git a/novus/enums/monetization.py b/novus/enums/monetization.py new file mode 100644 index 00000000..3257f7ca --- /dev/null +++ b/novus/enums/monetization.py @@ -0,0 +1,43 @@ +""" +Copyright (c) Kae Bartlett + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation, either version 3 of the License, or +any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +""" + +from __future__ import annotations + +from .utils import Enum + +__all__ = ( + 'SKUType', + 'EntitlementType', +) + + +class SKUType(Enum): + DURABLE = 2 + CONSUMABLE = 3 + SUBSCRIPTION = 5 + SUBSCRIPTION_GROUP = 6 + + +class EntitlementType(Enum): + PURCHASE = 1 + PREMIUM_SUBSCRIPTION = 2 + DEVELOPER_GIFT = 3 + TEST_MODE_PURCHASE = 4 + FREE_PURCHASE = 5 + USER_GIFT = 6 + PREMIUM_PURCHASE = 7 + APPLICATION_SUBSCRIPTION = 8 diff --git a/novus/ext/client/client.py b/novus/ext/client/client.py index fa60a8a0..59fca8e3 100644 --- a/novus/ext/client/client.py +++ b/novus/ext/client/client.py @@ -625,6 +625,29 @@ async def _handle_command_sync( log.info("Editing app command %s %s in guild %s", id, comm, guild_id) await edit_(id, **comm.application_command._to_data()) + async def fetch_application_id(self) -> int: + """ + Fetch and cache the application ID for the given token. + + Returns + ------- + int + The ID of the application + """ + + aid: int | None = self.state.application_id + if aid is None: + app = self.state.cache.application + if app is None: + app = await self.state.oauth2.get_current_bot_information() + self.state.cache.application = app + aid = app.id + return aid + + @property + def application_id(self) -> int: + return self.state.application_id + async def sync_commands( self, *, @@ -642,14 +665,7 @@ async def sync_commands( log.info(f"Syncing {len(command_length)} commands") # Get application ID - aid: int | None = self.state.cache.application_id - if aid is None: - app = self.state.cache.application - if app is None: - app = await self.state.oauth2.get_current_bot_information() - self.state.cache.application = app - aid = app.id - assert aid + aid = await self.fetch_application_id() # Group our commands by guild ID commands_by_guild: dict[int | None, dict[str, Command]] diff --git a/novus/flags/__init__.py b/novus/flags/__init__.py index 35cfb1e6..5f1b4c43 100644 --- a/novus/flags/__init__.py +++ b/novus/flags/__init__.py @@ -20,6 +20,7 @@ from .gateway import * from .guild import * from .message import * +from .monetization import * from .permissions import * from .user import * @@ -30,5 +31,6 @@ 'MessageFlags', 'Permissions', 'SystemChannelFlags', + 'SKUFlags', 'UserFlags', ) diff --git a/novus/flags/monetization.py b/novus/flags/monetization.py new file mode 100644 index 00000000..0a6230f0 --- /dev/null +++ b/novus/flags/monetization.py @@ -0,0 +1,41 @@ +""" +Copyright (c) Kae Bartlett + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation, either version 3 of the License, or +any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from vfflags import Flags + +__all__ = ( + 'SKUFlags', +) + + +class SKUFlags(Flags): + """Flags associated with SKUs.""" + + if TYPE_CHECKING: + available: bool + guild_subscription: bool + user_subscription: bool + + CREATE_FLAGS = { + "available": 1 << 2, + "guild_subscription": 1 << 7, + "user_subscription": 1 << 8, + } diff --git a/novus/models/__init__.py b/novus/models/__init__.py index 6ce30f88..a8d914b4 100644 --- a/novus/models/__init__.py +++ b/novus/models/__init__.py @@ -26,6 +26,7 @@ from .file import * from .guild import * from .guild_member import * +from .monetization import * from .interaction import * from .invite import * from .message import * @@ -68,6 +69,7 @@ 'ContextComandData', 'Embed', 'Emoji', + 'Entitlement', 'File', 'ForumTag', 'Guild', @@ -98,6 +100,7 @@ 'Reaction', 'Role', 'RoleSelectMenu', + 'SKU', 'ScheduledEvent', 'SelectOption', 'StageInstance', diff --git a/novus/models/interaction.py b/novus/models/interaction.py index 17c577ea..789783ba 100644 --- a/novus/models/interaction.py +++ b/novus/models/interaction.py @@ -36,6 +36,7 @@ from .guild import BaseGuild, Guild from .guild_member import GuildMember from .message import Attachment, Message +from .monetization import Entitlement from .role import Role from .ui.action_row import ActionRow from .ui.select_menu import SelectOption @@ -458,6 +459,9 @@ class Interaction(Generic[IData]): The user's locale. guild_locale: str | None The locale of the guild where the interaction was run. + entitlements: list[Entitlement] + A list of the entitlements that the user associated with the + interaction has. """ __slots__ = ( @@ -475,6 +479,7 @@ class Interaction(Generic[IData]): 'app_permissions', 'locale', 'guild_locale', + 'entitlements', '_responded', '_stream', '_stream_request', @@ -492,14 +497,20 @@ class Interaction(Generic[IData]): app_permissions: Permissions locale: str guild_locale: str | None + entitlements: list[Entitlement] + _stream: web.StreamResponse | None _stream_request: web.Request | None def __init__(self, *, state: HTTPConnection, data: payloads.Interaction): self.state = state self.id = try_snowflake(data["id"]) + + # For our responses self._stream = None self._stream_request = None + + # Parse relevant meta self.application_id = try_snowflake(data["application_id"]) self.type = data["type"] self.guild = self.state.cache.get_guild(data.get("guild_id")) @@ -508,8 +519,7 @@ def __init__(self, *, state: HTTPConnection, data: payloads.Interaction): self.guild = Guild(state=state, data=data["guild"]) else: self.guild = BaseGuild(state=state, data={"id": data["guild_id"]}) # pyright: ignore - channel = self.state.cache.get_channel(data.get("channel_id")) - if channel is None: + if (self.channel := self.state.cache.get_channel(data.get("channel_id"))) is None: self.channel = Channel.partial(self.state, data["channel_id"]) else: self.channel = channel @@ -525,7 +535,7 @@ def __init__(self, *, state: HTTPConnection, data: payloads.Interaction): guild_id=self.guild.id, # pyright: ignore ) else: - self.user = None # pyright: ignore # Ping interactions :( + self.user = None # pyright: ignore # Only ever happens for pings self.token = data["token"] self.version = data["version"] if "message" in data: @@ -538,6 +548,12 @@ def __init__(self, *, state: HTTPConnection, data: payloads.Interaction): self.app_permissions = Permissions.all() self.locale = data["locale"] self.guild_locale = data.get("guild_locale") + self.entitlements = [ + Entitlement(data=i, state=self.state) + for i in data.get("entitlements", []) + ] + + # Parse data data_object = None if "data" in data: data_dict = data["data"] diff --git a/novus/models/monetization.py b/novus/models/monetization.py new file mode 100644 index 00000000..52b05657 --- /dev/null +++ b/novus/models/monetization.py @@ -0,0 +1,243 @@ +""" +Copyright (c) Kae Bartlett + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation, either version 3 of the License, or +any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ..utils import DiscordDatetime, parse_timestamp, try_snowflake, try_id +from ..flags import SKUFlags +from ..enums import SKUType + +if TYPE_CHECKING: + from ..api import HTTPConnection + from .. import payloads + +__all__ = ( + 'SKU', + 'Entitlement', +) + + +class SKU: + """ + A Discord stock-keeping unit - an item that can be purchased. + + Attributes + ---------- + id : int + The ID of the SKU. + type : int + The type of the SKU. + + .. seealso: `novus.SKUType` + application_id : int + The ID of the application that the SKU is associated with. + name : str + The name of the SKU. + slug : str + A system-generated URL slug based on the SKU's name. + flags : `novus.SKUFlags` + Flags associated with the SKU. + """ + + def __init__(self, *, data: payloads.SKU, state: HTTPConnection): + self.id: int = try_snowflake(data["id"]) + self.type: SKUType = SKUType(data["type"]) + self.application_id: int = try_snowflake(data["application_id"]) + self.name: str = data["name"] + self.slug: str = data["slug"] + self.flags: SKUFlags = SKUFlags(data["flags"]) + + # API methods + + async def list_skus(cls, state: HTTPConnection) -> list[SKU]: + """ + List all of the SKUs that the application has created. + + Parameters + ---------- + state : novus.api.HTTPConnection + The API connection. + """ + + return await state.monetization.list_skus(state.application_id) + + +class Entitlement: + """ + A purchase that a user has made associated with your application. + + Attributes + ---------- + id : int + The ID of the entitlement. + sku_id : int + The ID of the purchased SKU. + application_id : int + The ID of the application that the SKU belongs to. + user_id : int | None + The user that was granted access to the entitlement's SKU. + type : int + The type of entitlement. + + .. seealso:: `novus.EntitlementType` + deleted : bool + Whether or not the entitlement was deleted. + starts_at : `novus.utils.DiscordDatetime` | None + The date from which the entitlement is valid. Not present on test + entitlements. + ends_at : `novus.utils.DiscordDatetime` | None + The date at which the entitlement is no longer valid. Not present on + test entitlements. + guild_id : int | None + The ID of the guild that was granted access to the entitlement's SKU. + consumed : bool | None + For consumable items, whether or not the entitlement has been consumed. + """ + + __slots__ = ( + 'id', + 'sku_id', + 'application_id', + 'user_id', + 'type', + 'deleted', + 'starts_at', + 'ends_at', + 'guild_id', + 'consumed', + 'state', + ) + + def __init__(self, *, data: payloads.Entitlement, state: HTTPConnection): + self.id: int = try_snowflake(data["id"]) + self.sku_id: int = try_snowflake(data["sku_id"]) + self.application_id: int = try_snowflake(data["application_id"]) + self.user_id: int | None = try_snowflake(data.get("user_id")) + self.type: int = data["type"] + self.deleted: bool = data["deleted"] + self.starts_at: DiscordDatetime | None = parse_timestamp(data.get("starts_at")) + self.ends_at: DiscordDatetime | None = parse_timestamp(data.get("ends_at")) + self.guild_id: int | None = data.get("guild_id") + self.consumed: bool | None = data.get("consumed") + self.state = state + + # API methods + + # @classmethod + # async def list_entitlements( + # cls, + # state: HTTPConnection, + # *, + # limit: int = 100, + # around: int | abc.Snowflake | Message = MISSING, + # before: int | abc.Snowflake | Message = MISSING, + # after: int | abc.Snowflake | Message = MISSING) -> list[Entitlement]: + # """ + # Get a number of messages from the channel. + + # Parameters + # ---------- + # limit : int + # The number of messages that you want to get. Maximum 100. + # around : int | novus.abc.Snowflake + # Get messages around this ID. + # Only one of ``around``, ``before``, and ``after`` can be set. + # before : int | novus.abc.Snowflake + # Get messages before this ID. + # Only one of ``around``, ``before``, and ``after`` can be set. + # after : int | novus.abc.Snowflake + # Get messages after this ID. + # Only one of ``around``, ``before``, and ``after`` can be set. + + # Returns + # ------- + # list[novus.Message] + # The messages that were retrieved. + # """ + + # params: dict[str, int] = {} + # add_not_missing(params, "limit", limit) + # add_not_missing(params, "around", around, try_id) + # add_not_missing(params, "before", before, try_id) + # add_not_missing(params, "after", after, try_id) + # return await self.state.channel.get_channel_messages( + # self.id, + # **params, + # ) + + # @classmethod + # def entitlement( + # cls, + # state: HTTPConnection, + # *, + # limit: int | None = 100, + # before: int | abc.Snowflake | Message = MISSING, + # after: int | abc.Snowflake | Message = MISSING) -> APIIterator[Message]: + # """ + # Get an iterator of messages from a channel. + + # Examples + # -------- + + # .. code-block:: + + # async for message in channel.messages(limit=1_000): + # print(message.content) + + # .. code-block:: + + # messages = await channel.messages(limit=200).flatten() + + # Parameters + # ---------- + # limit : int + # The number of messages that you want to get. + # before : int | novus.abc.Snowflake + # Get messages before this ID. + # Only one of ``around``, ``before``, and ``after`` can be set. + # after : int | novus.abc.Snowflake + # Get messages after this ID. + # Only one of ``around``, ``before``, and ``after`` can be set. + + # Returns + # ------- + # APIIterator[novus.Message] + # The messages that were retrieved, as a generator. + # """ + + # from ..api import APIIterator # circular import + # return APIIterator( + # method=self.fetch_messages, + # before=before, + # after=after, + # limit=limit, + # method_limit=100, + # ) + + async def consume(self) -> None: + """ + Consume the entitlement. + """ + + await self.state.monetization.consume_entitlement( + self.application_id, + self.id, + ) + self.consumed = True + return None diff --git a/novus/payloads/__init__.py b/novus/payloads/__init__.py index b048f1aa..33db82ca 100644 --- a/novus/payloads/__init__.py +++ b/novus/payloads/__init__.py @@ -31,6 +31,7 @@ from .interaction import * from .invite import * from .message import * +from .monetization import * from .oauth2 import * from .stage_instance import * from .sticker import * @@ -72,6 +73,7 @@ 'Embed', 'EmbedType', 'Emoji', + 'Entitlement', 'ForumDefaultReaction', 'ForumTag', 'GatewayGuild', @@ -115,6 +117,7 @@ 'Reaction', 'Role', 'RoleTags', + 'SKU', 'SelectMenu', 'SelectOption', 'Snowflake', diff --git a/novus/payloads/interaction.py b/novus/payloads/interaction.py index cada7f1b..d55139e8 100644 --- a/novus/payloads/interaction.py +++ b/novus/payloads/interaction.py @@ -17,7 +17,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Literal, TypedDict +from typing import TYPE_CHECKING, Any, Literal, TypedDict, List from typing_extensions import NotRequired @@ -27,6 +27,7 @@ ApplicationCommandOptionType, Attachment, Channel, + Entitlement, Guild, GuildMember, Message, @@ -108,3 +109,6 @@ class Interaction(TypedDict): app_permissions: NotRequired[str] locale: str guild_locale: NotRequired[str] + entitlements: List[Entitlement] + authorizing_integration_owners: dict[str, Any] + context: NotRequired[int] diff --git a/novus/payloads/monetization.py b/novus/payloads/monetization.py new file mode 100644 index 00000000..a046319c --- /dev/null +++ b/novus/payloads/monetization.py @@ -0,0 +1,52 @@ +""" +Copyright (c) Kae Bartlett + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation, either version 3 of the License, or +any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, TypedDict + +from typing_extensions import NotRequired + +if TYPE_CHECKING: + from ._util import Snowflake, Timestamp + +__all__ = ( + 'SKU', + 'Entitlement', +) + + +class SKU(TypedDict): + id: Snowflake + type: int + 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: int + deleted: bool + starts_at: NotRequired[Timestamp] + ends_at: NotRequired[Timestamp] + guild_id: NotRequired[Snowflake] + consumed: NotRequired[bool]