From 9a6c4e1e359a4bd3fe4aa754207c4cdafaedb8d7 Mon Sep 17 00:00:00 2001 From: dolfies Date: Thu, 2 Feb 2023 18:31:47 -0500 Subject: [PATCH] Implement (almost) the entirety of the applications, billing, and store API (#449) * Initial implementation * Expose various rich methods to fetch the new models * Add localize parameters and remove useless payment_source parameters from front-facing fetch methods * Implement fetching and (partially) redeeming gifts * Slot remaining models * Correctly document Gift.redeem() channel parameter * Implement some stuffs, fix more stuffs, add creating/editing skus/store listings * Various context properties fixes * Fix various types, expose SubscriptionPlan * (Partially) implement purchasing SKUs and gift flags * Finish and clean-up store/applications API implementations * Implement build uls, missing sub plan params, purchase sku ret * Fix upload_files() warning * Formatter pass * Normalize include_x to with_x, add various small missing things * Update sub on manual invoice payment instead of returning new object * Black pass * Implement missing integrations/applications API shit * Implement Application.store_listing_sku_id * Expose richer subscription metadata guild info * Implement SKU.system_requirements localization and modification * Black pass * Implement premium usage * Implement application whitelist * Implement active developer program enrollment * Readd new team members to cache * Polishing * Implement leaving active developer program * Type everything * Expose everything * Implement relationship activity statistics, improve model * Black pass * Document everything * Add crunchyroll connection type (#426) * Fix type-checking error in PrivateChannel ABC (#427) * Update required property fetching to new domain * Pin black to v22.6 * Get pyright to shut up * Black pass * Get pyright to shut up --- discord/__init__.py | 15 +- discord/abc.py | 2 +- discord/appinfo.py | 3686 +++++++++++++++++++++++++++----- discord/asset.py | 20 + discord/billing.py | 381 ++++ discord/calls.py | 2 +- discord/channel.py | 120 +- discord/client.py | 1454 ++++++++++++- discord/commands.py | 16 +- discord/connections.py | 79 +- discord/entitlements.py | 632 ++++++ discord/enums.py | 472 +++- discord/errors.py | 18 + discord/file.py | 17 +- discord/flags.py | 610 +++++- discord/guild.py | 177 +- discord/guild_premium.py | 320 +++ discord/http.py | 1591 ++++++++++++-- discord/integrations.py | 160 +- discord/invite.py | 22 +- discord/library.py | 280 +++ discord/member.py | 8 +- discord/metadata.py | 134 ++ discord/modal.py | 6 +- discord/payments.py | 290 +++ discord/profile.py | 67 +- discord/promotions.py | 306 +++ discord/role.py | 2 +- discord/state.py | 156 +- discord/store.py | 2281 ++++++++++++++++++++ discord/subscriptions.py | 859 ++++++++ discord/team.py | 344 ++- discord/tracking.py | 16 +- discord/types/appinfo.py | 203 +- discord/types/billing.py | 78 + discord/types/channel.py | 6 + discord/types/entitlements.py | 95 + discord/types/gateway.py | 96 +- discord/types/guild.py | 6 +- discord/types/integration.py | 24 +- discord/types/invite.py | 6 +- discord/types/library.py | 44 + discord/types/payments.py | 61 + discord/types/promotions.py | 79 + discord/types/store.py | 152 ++ discord/types/subscriptions.py | 161 ++ discord/types/team.py | 22 +- discord/types/user.py | 12 +- discord/user.py | 45 +- discord/utils.py | 107 +- docs/api.rst | 2251 +++++++++++++++++-- docs/migrating.rst | 54 +- pyproject.toml | 1 + 53 files changed, 16670 insertions(+), 1376 deletions(-) create mode 100644 discord/billing.py create mode 100644 discord/entitlements.py create mode 100644 discord/guild_premium.py create mode 100644 discord/library.py create mode 100644 discord/metadata.py create mode 100644 discord/payments.py create mode 100644 discord/promotions.py create mode 100644 discord/store.py create mode 100644 discord/subscriptions.py create mode 100644 discord/types/billing.py create mode 100644 discord/types/entitlements.py create mode 100644 discord/types/library.py create mode 100644 discord/types/payments.py create mode 100644 discord/types/promotions.py create mode 100644 discord/types/store.py create mode 100644 discord/types/subscriptions.py diff --git a/discord/__init__.py b/discord/__init__.py index 88b2719d7..fa6bc1195 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -19,15 +19,12 @@ __path__ = __import__('pkgutil').extend_path(__path__, __name__) import logging from typing import Literal, NamedTuple -from . import ( - abc as abc, - opus as opus, - utils as utils, -) +from . import abc as abc, opus as opus, utils as utils from .activity import * from .appinfo import * from .asset import * from .audit_logs import * +from .billing import * from .calls import * from .channel import * from .client import * @@ -37,25 +34,31 @@ from .components import * from .connections import * from .embeds import * from .emoji import * +from .entitlements import * from .enums import * from .errors import * from .file import * from .flags import * from .guild import * from .guild_folder import * +from .guild_premium import * from .handlers import * from .integrations import * from .interactions import * from .invite import * +from .library import * from .member import * from .mentions import * from .message import * +from .metadata import * from .modal import * from .object import * from .partial_emoji import * +from .payments import * from .permissions import * from .player import * from .profile import * +from .promotions import * from .raw_models import * from .reaction import * from .relationship import * @@ -64,6 +67,8 @@ from .scheduled_event import * from .settings import * from .stage_instance import * from .sticker import * +from .store import * +from .subscriptions import * from .team import * from .template import * from .threads import * diff --git a/discord/abc.py b/discord/abc.py index ae8d0bb88..f762f8ec2 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -363,7 +363,7 @@ class PrivateChannel(Snowflake, Protocol): def _add_call(self, **kwargs): raise NotImplementedError - def _update(self, **kwargs) -> None: + def _update(self, *args) -> None: raise NotImplementedError diff --git a/discord/appinfo.py b/discord/appinfo.py index 45cc7d0d7..eec277505 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -24,735 +24,3250 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations -from typing import Collection, List, TYPE_CHECKING, Literal, Optional +from datetime import datetime +from typing import ( + TYPE_CHECKING, + AsyncIterator, + Collection, + List, + Literal, + Mapping, + Optional, + Sequence, + Tuple, + Union, + overload, +) +from urllib.parse import quote from . import utils -from .asset import Asset -from .enums import ApplicationType, ApplicationVerificationState, RPCApplicationState, StoreApplicationState, try_enum -from .flags import ApplicationFlags +from .asset import Asset, AssetMixin +from .entitlements import Entitlement, GiftBatch +from .enums import ( + ApplicationAssetType, + ApplicationBuildStatus, + ApplicationDiscoverabilityState, + ApplicationMembershipState, + ApplicationType, + ApplicationVerificationState, + Distributor, + EmbeddedActivityOrientation, + EmbeddedActivityPlatform, + Locale, + RPCApplicationState, + StoreApplicationState, + UserFlags, + try_enum, +) +from .flags import ApplicationDiscoveryFlags, ApplicationFlags from .mixins import Hashable +from .object import OLDEST_OBJECT, Object from .permissions import Permissions -from .user import User +from .store import SKU, StoreAsset, StoreListing, SystemRequirements +from .team import Team +from .user import User, _UserTag +from .utils import _bytes_to_base64_data, _parse_localizations if TYPE_CHECKING: - from .abc import Snowflake, User as abcUser + from datetime import date + + from .abc import Snowflake, SnowflakeTime + from .enums import SKUAccessLevel, SKUFeature, SKUGenre, SKUType + from .file import File from .guild import Guild + from .metadata import MetadataObject + from .state import ConnectionState + from .store import ContentRating from .types.appinfo import ( - AppInfo as AppInfoPayload, - PartialAppInfo as PartialAppInfoPayload, - Team as TeamPayload, + EULA as EULAPayload, + Achievement as AchievementPayload, + ActivityStatistics as ActivityStatisticsPayload, + Application as ApplicationPayload, + Asset as AssetPayload, + Branch as BranchPayload, + Build as BuildPayload, + Company as CompanyPayload, + EmbeddedActivityConfig as EmbeddedActivityConfigPayload, + GlobalActivityStatistics as GlobalActivityStatisticsPayload, + IntegrationApplication as IntegrationApplicationPayload, + Manifest as ManifestPayload, + ManifestLabel as ManifestLabelPayload, + PartialApplication as PartialApplicationPayload, + WhitelistedUser as WhitelistedUserPayload, ) - from .state import ConnectionState + from .types.user import PartialUser as PartialUserPayload __all__ = ( + 'Company', + 'EULA', + 'Achievement', + 'ThirdPartySKU', + 'EmbeddedActivityConfig', 'ApplicationBot', - 'ApplicationCompany', 'ApplicationExecutable', 'ApplicationInstallParams', - 'Application', + 'ApplicationAsset', + 'ApplicationActivityStatistics', + 'ManifestLabel', + 'Manifest', + 'ApplicationBuild', + 'ApplicationBranch', + 'ApplicationTester', 'PartialApplication', - 'InteractionApplication', + 'Application', + 'IntegrationApplication', ) MISSING = utils.MISSING -class ApplicationBot(User): - """Represents a bot attached to an application. - - .. versionadded:: 2.0 +class Company(Hashable): + """Represents a Discord company. This is usually the developer or publisher of an application. .. container:: operations .. describe:: x == y - Checks if two bots are equal. + Checks if two companies are equal. .. describe:: x != y - Checks if two bots are not equal. + Checks if two companies are not equal. .. describe:: hash(x) - Return the bot's hash. + Return the company's hash. .. describe:: str(x) - Returns the bot's name with discriminator. + Returns the company's name. + + .. versionadded:: 2.0 Attributes ----------- - application: :class:`Application` - The application that the bot is attached to. - public: :class:`bool` - Whether the bot can be invited by anyone or if it is locked - to the application owner. - require_code_grant: :class:`bool` - Whether the bot requires the completion of the full OAuth2 code - grant flow to join. + id: :class:`int` + The company's ID. + name: :class:`str` + The company's name. """ - __slots__ = ('application', 'public', 'require_code_grant') - - def __init__(self, *, data, state: ConnectionState, application: Application): - super().__init__(state=state, data=data) - self.application = application - self.public: bool = data['public'] - self.require_code_grant: bool = data['require_code_grant'] - - async def reset_token(self) -> str: - """|coro| - - Resets the bot's token. - - Raises - ------ - HTTPException - Resetting the token failed. - - Returns - ------- - :class:`str` - The new token. - """ - data = await self._state.http.reset_token(self.application.id) - return data['token'] - - async def edit( - self, - *, - public: bool = MISSING, - require_code_grant: bool = MISSING, - ) -> None: - """|coro| + __slots__ = ('id', 'name') - Edits the bot. + def __init__(self, data: CompanyPayload): + self.id: int = int(data['id']) + self.name: str = data['name'] - Parameters - ----------- - public: :class:`bool` - Whether the bot is public or not. - require_code_grant: :class:`bool` - Whether the bot requires a code grant or not. + def __repr__(self) -> str: + return f'' - Raises - ------ - Forbidden - You are not allowed to edit this bot. - HTTPException - Editing the bot failed. - """ - payload = {} - if public is not MISSING: - payload['bot_public'] = public - if require_code_grant is not MISSING: - payload['bot_require_code_grant'] = require_code_grant + def __str__(self) -> str: + return self.name - data = await self._state.http.edit_application(self.application.id, payload=payload) - self.public = data.get('bot_public', True) - self.require_code_grant = data.get('bot_require_code_grant', False) - self.application._update(data) +class EULA(Hashable): + """Represents the EULA for an application. -class ApplicationCompany(Hashable): - """Represents a developer or publisher of an application. + This is usually found on applications that are a game. .. container:: operations .. describe:: x == y - Checks if two companies are equal. + Checks if two EULAs are equal. .. describe:: x != y - Checks if two companies are not equal. + Checks if two EULAs are not equal. .. describe:: hash(x) - Return the company's hash. + Returns the EULA's hash. .. describe:: str(x) - Returns the company's name. + Returns the EULA's name. .. versionadded:: 2.0 Attributes ----------- id: :class:`int` - The company's ID. + The EULA's ID. name: :class:`str` - The company's name. - application: Union[:class:`PartialApplication`, :class:`Application`] - The application that the company developed or published. + The EULA's name. + content: :class:`str` + The EULA's content. """ - __slots__ = ( - 'id', - 'name', - 'application', - ) + __slots__ = ('id', 'name', 'content') - def __init__(self, *, data: dict, application: PartialApplication): + def __init__(self, data: EULAPayload) -> None: self.id: int = int(data['id']) self.name: str = data['name'] - self.application = application + self.content: str = data['content'] + + def __repr__(self) -> str: + return f'' def __str__(self) -> str: return self.name -class ApplicationExecutable: - """Represents an application executable. - - .. versionadded:: 2.0 +class Achievement(Hashable): + """Represents a Discord application achievement. .. container:: operations + .. describe:: x == y + + Checks if two achievements are equal. + + .. describe:: x != y + + Checks if two achievements are not equal. + + .. describe:: hash(x) + + Return the achievement's hash. + .. describe:: str(x) - Returns the executable's name. + Returns the achievement's name. + + .. versionadded:: 2.0 Attributes ----------- + id: :class:`int` + The achievement's ID. name: :class:`str` - The name of the executable. - os: :class:`str` - The operating system the executable is for. - launcher: :class:`bool` - Whether the executable is a launcher or not. - application: Union[:class:`PartialApplication`, :class:`Application`] - The application that the executable is for. + The achievement's name. + name_localizations: Dict[:class:`Locale`, :class:`str`] + The achievement's name localized to other languages, if available. + description: :class:`str` + The achievement's description. + description_localizations: Dict[:class:`Locale`, :class:`str`] + The achievement's description localized to other languages, if available. + application_id: :class:`int` + The application ID that the achievement belongs to. + secure: :class:`bool` + Whether the achievement is secure. + secret: :class:`bool` + Whether the achievement is secret. """ __slots__ = ( + 'id', 'name', - 'os', - 'launcher', - 'application', + 'name_localizations', + 'description', + 'description_localizations', + 'application_id', + 'secure', + 'secret', + '_icon', + '_state', ) - def __init__(self, *, data: dict, application: PartialApplication): - self.name: str = data['name'] - self.os: Literal['win32', 'linux', 'darwin'] = data['os'] - self.launcher: bool = data['is_launcher'] - self.application = application + if TYPE_CHECKING: + name: str + name_localizations: dict[Locale, str] + description: str + description_localizations: dict[Locale, str] + + def __init__(self, *, data: AchievementPayload, state: ConnectionState): + self._state = state + self._update(data) + + def _update(self, data: AchievementPayload): + self.id: int = int(data['id']) + self.application_id: int = int(data['application_id']) + self.secure: bool = data.get('secure', False) + self.secret: bool = data.get('secret', False) + self._icon = data.get('icon', data.get('icon_hash')) + + self.name, self.name_localizations = _parse_localizations(data, 'name') + self.description, self.description_localizations = _parse_localizations(data, 'description') def __repr__(self) -> str: - return f'' + return f'' def __str__(self) -> str: return self.name + @property + def icon(self) -> Asset: + """:class:`Asset`: Returns the achievement's icon.""" + return Asset._from_achievement_icon(self._state, self.application_id, self.id, self._icon) -class ApplicationInstallParams: - """Represents an application's authorization parameters. + async def edit( + self, + *, + name: str = MISSING, + name_localizations: Mapping[Locale, str] = MISSING, + description: str = MISSING, + description_localizations: Mapping[Locale, str] = MISSING, + icon: bytes = MISSING, + secure: bool = MISSING, + secret: bool = MISSING, + ) -> None: + """|coro| - .. versionadded:: 2.0 + Edits the achievement. - .. container:: operations + All parameters are optional. - .. describe:: str(x) + Parameters + ----------- + name: :class:`str` + The achievement's name. + name_localizations: Dict[:class:`Locale`, :class:`str`] + The achievement's name localized to other languages. + description: :class:`str` + The achievement's description. + description_localizations: Dict[:class:`Locale`, :class:`str`] + The achievement's description localized to other languages. + icon: :class:`bytes` + A :term:`py:bytes-like object` representing the new icon. + secure: :class:`bool` + Whether the achievement is secure. + secret: :class:`bool` + Whether the achievement is secret. + + Raises + ------- + Forbidden + You do not have permissions to edit the achievement. + HTTPException + Editing the achievement failed. + """ + payload = {} + if secure is not MISSING: + payload['secure'] = secure + if secret is not MISSING: + payload['secret'] = secret + if icon is not MISSING: + payload['icon'] = utils._bytes_to_base64_data(icon) + + if name is not MISSING or name_localizations is not MISSING: + localizations = (name_localizations or {}) if name_localizations is not MISSING else self.name_localizations + payload['name'] = {'default': name or self.name, 'localizations': {str(k): v for k, v in localizations.items()}} + if description is not MISSING or description_localizations is not MISSING: + localizations = ( + (name_localizations or {}) if description_localizations is not MISSING else self.description_localizations + ) + payload['description'] = { + 'default': description or self.description, + 'localizations': {str(k): v for k, v in localizations.items()}, + } + + data = await self._state.http.edit_achievement(self.application_id, self.id, payload) + self._update(data) + + async def update(self, user: Snowflake, percent_complete: int) -> None: + """|coro| + + Updates the achievement progress for a specific user. + + Parameters + ----------- + user: :class:`User` + The user to update the achievement for. + percent_complete: :class:`int` + The percent complete for the achievement. + + Raises + ------- + Forbidden + You do not have permissions to update the achievement. + HTTPException + Updating the achievement failed. + """ + await self._state.http.update_user_achievement(self.application_id, self.id, user.id, percent_complete) + + async def delete(self): + """|coro| + + Deletes the achievement. + + Raises + ------- + Forbidden + You do not have permissions to delete the achievement. + HTTPException + Deleting the achievement failed. + """ + await self._state.http.delete_achievement(self.application_id, self.id) - Returns the authorization URL. + +class ThirdPartySKU: + """Represents an application's primary SKU on third-party platforms. + + .. versionadded:: 2.0 Attributes - ---------- - id: :class:`int` - The application's ID. - scopes: List[:class:`str`] - The list of `OAuth2 scopes `_ - to add the application with. - permissions: :class:`Permissions` - The permissions to grant to the added bot. + ----------- + application: :class:`PartialApplication` + The application that the SKU belongs to. + distributor: :class:`Distributor` + The distributor of the SKU. + id: Optional[:class:`str`] + The product ID. + sku_id: Optional[:class:`str`] + The SKU ID. """ - __slots__ = ('id', 'scopes', 'permissions') + __slots__ = ('application', 'distributor', 'id', 'sku_id') - def __init__(self, id: int, data: dict): - self.id: int = id - self.scopes: List[str] = data.get('scopes', []) - self.permissions: Permissions = Permissions(int(data.get('permissions', 0))) + def __init__(self, *, data: dict, application: PartialApplication): + self.application = application + self.distributor: Distributor = try_enum(Distributor, data['distributor']) + self.id: Optional[str] = data.get('id') + self.sku_id: Optional[str] = data.get('sku_id') def __repr__(self) -> str: - return f'' + return f'' - def __str__(self) -> str: - return self.url + +class EmbeddedActivityConfig: + """Represents an application's embedded activity configuration. + + .. versionadded:: 2.0 + + Attributes + ----------- + application: :class:`PartialApplication` + The application that the configuration is for. + supported_platforms: List[:class:`EmbeddedActivityPlatform`] + A list of platforms that the application supports. + orientation_lock_state: :class:`EmbeddedActivityOrientation` + The mobile orientation lock state of the application. + """ + + __slots__ = ( + 'application', + 'supported_platforms', + 'orientation_lock_state', + 'premium_tier_level', + '_preview_video_asset_id', + ) + + def __init__(self, *, data: EmbeddedActivityConfigPayload, application: PartialApplication) -> None: + self.application: PartialApplication = application + self._update(data) + + def _update(self, data: EmbeddedActivityConfigPayload) -> None: + self.supported_platforms: List[EmbeddedActivityPlatform] = [ + try_enum(EmbeddedActivityPlatform, platform) for platform in data.get('supported_platforms', []) + ] + self.orientation_lock_state: EmbeddedActivityOrientation = try_enum( + EmbeddedActivityOrientation, data.get('default_orientation_lock_state', 0) + ) + self.premium_tier_level: int = data.get('activity_premium_tier_level', 0) + self._preview_video_asset_id = utils._get_as_snowflake(data, 'preview_video_asset_id') @property - def url(self) -> str: - """:class:`str`: The URL to add the application with the parameters.""" - return utils.oauth_url(self.id, permissions=self.permissions, scopes=self.scopes) + def preview_video_asset(self) -> Optional[ApplicationAsset]: + """Optional[:class:`ApplicationAsset`]: The preview video asset of the embedded activity, if available.""" + if self._preview_video_asset_id is None: + return None + app = self.application + return ApplicationAsset._from_embedded_activity_config(app, self._preview_video_asset_id) + + async def edit( + self, + *, + supported_platforms: List[EmbeddedActivityPlatform] = MISSING, + orientation_lock_state: EmbeddedActivityOrientation = MISSING, + preview_video_asset: Optional[Snowflake] = MISSING, + ) -> None: + """|coro| + Edits the application's embedded activity configuration. -class PartialApplication(Hashable): - """Represents a partial Application. + Parameters + ----------- + supported_platforms: List[:class:`EmbeddedActivityPlatform`] + A list of platforms that the application supports. + orientation_lock_state: :class:`EmbeddedActivityOrientation` + The mobile orientation lock state of the application. + preview_video_asset: Optional[:class:`ApplicationAsset`] + The preview video asset of the embedded activity. - .. versionadded:: 2.0 + Raises + ------- + Forbidden + You are not allowed to edit this application's configuration. + HTTPException + Editing the configuration failed. + """ + data = await self.application._state.http.edit_embedded_activity_config( + self.application.id, + supported_platforms=[str(x) for x in (supported_platforms or [])], + orientation_lock_state=int(orientation_lock_state), + preview_video_asset_id=(preview_video_asset.id if preview_video_asset else None) + if preview_video_asset is not MISSING + else None, + ) + self._update(data) + + +class ApplicationBot(User): + """Represents a bot attached to an application. .. container:: operations .. describe:: x == y - Checks if two applications are equal. + Checks if two bots are equal. .. describe:: x != y - Checks if two applications are not equal. + Checks if two bots are not equal. .. describe:: hash(x) - Return the application's hash. + Return the bot's hash. .. describe:: str(x) - Returns the application's name. + Returns the bot's name with discriminator. + + .. versionadded:: 2.0 Attributes - ------------- - id: :class:`int` - The application ID. - name: :class:`str` - The application name. - description: :class:`str` - The application description. - rpc_origins: List[:class:`str`] - A list of RPC origin URLs, if RPC is enabled. - verify_key: :class:`str` - The hex encoded key for verification in interactions and the - GameSDK's `GetTicket `_. - terms_of_service_url: Optional[:class:`str`] - The application's terms of service URL, if set. - privacy_policy_url: Optional[:class:`str`] - The application's privacy policy URL, if set. + ----------- + application: :class:`Application` + The application that the bot is attached to. public: :class:`bool` - Whether the integration can be invited by anyone or if it is locked + Whether the bot can be invited by anyone or if it is locked to the application owner. require_code_grant: :class:`bool` - Whether the integration requires the completion of the full OAuth2 code - grant flow to join - max_participants: Optional[:class:`int`] - The max number of people that can participate in the activity. - Only available for embedded activities. - premium_tier_level: Optional[:class:`int`] - The required premium tier level to launch the activity. - Only available for embedded activities. - type: :class:`ApplicationType` - The type of application. - tags: List[:class:`str`] - A list of tags that describe the application. - overlay: :class:`bool` - Whether the application has a Discord overlay or not. - aliases: List[:class:`str`] - A list of aliases that can be used to identify the application. Only available for specific applications. - developers: List[:class:`ApplicationCompany`] - A list of developers that developed the application. Only available for specific applications. - publishers: List[:class:`ApplicationCompany`] - A list of publishers that published the application. Only available for specific applications. - executables: List[:class:`ApplicationExecutable`] - A list of executables that are the application's. Only available for specific applications. - custom_install_url: Optional[:class:`str`] - The custom URL to use for authorizing the application, if specified. - install_params: Optional[:class:`ApplicationInstallParams`] - The parameters to use for authorizing the application, if specified. + Whether the bot requires the completion of the full OAuth2 code + grant flow to join. + """ + + __slots__ = ('application', 'public', 'require_code_grant') + + def __init__(self, *, data: PartialUserPayload, state: ConnectionState, application: Application): + super().__init__(state=state, data=data) + self.application = application + + def _update(self, data: PartialUserPayload) -> None: + super()._update(data) + self.public: bool = data.get('public', True) + self.require_code_grant: bool = data.get('require_code_grant', False) + + def __repr__(self) -> str: + return f'' + + @property + def bio(self) -> Optional[str]: + """Optional[:class:`str`]: Returns the bot's 'about me' section.""" + return self.application.description or None + + @property + def mfa_enabled(self) -> bool: + """:class:`bool`: Whether the bot has MFA turned on and working. This follows the bot owner's value.""" + if self.application.owner.public_flags.team_user: + return True + return self._state.user.mfa_enabled # type: ignore # user is always present at this point + + @property + def verified(self) -> bool: + """:class:`bool`: Whether the bot's email has been verified. This follows the bot owner's value.""" + # Not possible to have a bot without a verified email + return True + + async def edit( + self, + *, + username: str = MISSING, + avatar: Optional[bytes] = MISSING, + bio: Optional[str] = MISSING, + public: bool = MISSING, + require_code_grant: bool = MISSING, + ) -> None: + """|coro| + + Edits the bot. + + All parameters are optional. + + Parameters + ----------- + username: :class:`str` + The new username you wish to change your bot to. + avatar: Optional[:class:`bytes`] + A :term:`py:bytes-like object` representing the image to upload. + Could be ``None`` to denote no avatar. + bio: Optional[:class:`str`] + Your bot's 'about me' section. This is just the application description. + Could be ``None`` to represent no 'about me'. + public: :class:`bool` + Whether the bot is public or not. + require_code_grant: :class:`bool` + Whether the bot requires a code grant or not. + + Raises + ------ + Forbidden + You are not allowed to edit this bot. + HTTPException + Editing the bot failed. + """ + payload = {} + if username is not MISSING: + payload['username'] = username + if avatar is not MISSING: + if avatar is not None: + payload['avatar'] = _bytes_to_base64_data(avatar) + else: + payload['avatar'] = None + + if payload: + data = await self._state.http.edit_bot(self.application.id, payload) + data['public'] = self.public # type: ignore + data['require_code_grant'] = self.require_code_grant # type: ignore + self._update(data) + payload = {} + + if public is not MISSING: + payload['bot_public'] = public + if require_code_grant is not MISSING: + payload['bot_require_code_grant'] = require_code_grant + if bio is not MISSING: + payload['description'] = bio + + if payload: + data = await self._state.http.edit_application(self.application.id, payload) + self.application._update(data) + + async def token(self) -> None: + """|coro| + + Gets the bot's token. + + This revokes all previous tokens. + + Raises + ------ + Forbidden + You are not allowed to reset the token. + HTTPException + Resetting the token failed. + + Returns + ------- + :class:`str` + The new token. + """ + data = await self._state.http.reset_bot_token(self.application.id) + return data['token'] + + +class ApplicationExecutable: + """Represents an application executable. + + .. container:: operations + + .. describe:: str(x) + + Returns the executable's name. + + .. versionadded:: 2.0 + + Attributes + ----------- + name: :class:`str` + The name of the executable. + os: :class:`str` + The operating system the executable is for. + launcher: :class:`bool` + Whether the executable is a launcher or not. + application: :class:`PartialApplication` + The application that the executable is for. """ __slots__ = ( - '_state', - 'id', 'name', - 'description', - 'rpc_origins', - 'verify_key', - 'terms_of_service_url', - 'privacy_policy_url', - '_icon', - '_flags', - '_cover_image', - '_splash', - 'public', - 'require_code_grant', - 'type', - 'hook', - 'premium_tier_level', - 'tags', - 'max_participants', - 'overlay', - 'overlay_compatibility_hook', - 'aliases', - 'developers', - 'publishers', - 'executables', - 'custom_install_url', - 'install_params', + 'os', + 'launcher', + 'application', ) - def __init__(self, *, state: ConnectionState, data: PartialAppInfoPayload): - self._state: ConnectionState = state - self._update(data) + def __init__(self, *, data: dict, application: PartialApplication): + self.name: str = data['name'] + self.os: Literal['win32', 'linux', 'darwin'] = data['os'] + self.launcher: bool = data['is_launcher'] + self.application = application + + def __repr__(self) -> str: + return f'' def __str__(self) -> str: return self.name - def _update(self, data: PartialAppInfoPayload) -> None: - self.id: int = int(data['id']) - self.name: str = data['name'] - self.description: str = data['description'] - self.rpc_origins: Optional[List[str]] = data.get('rpc_origins') or [] - self.verify_key: str = data['verify_key'] - - self.developers: List[ApplicationCompany] = [ - ApplicationCompany(data=d, application=self) for d in data.get('developers', []) - ] - self.publishers: List[ApplicationCompany] = [ - ApplicationCompany(data=d, application=self) for d in data.get('publishers', []) - ] - self.executables: List[ApplicationExecutable] = [ - ApplicationExecutable(data=e, application=self) for e in data.get('executables', []) - ] - self.aliases: List[str] = data.get('aliases', []) - - self._icon: Optional[str] = data.get('icon') - self._cover_image: Optional[str] = data.get('cover_image') - self._splash: Optional[str] = data.get('splash') - self.terms_of_service_url: Optional[str] = data.get('terms_of_service_url') - self.privacy_policy_url: Optional[str] = data.get('privacy_policy_url') - self._flags: int = data.get('flags', 0) - self.type: ApplicationType = try_enum(ApplicationType, data.get('type')) - self.hook: bool = data.get('hook', False) - self.max_participants: Optional[int] = data.get('max_participants') - self.premium_tier_level: Optional[int] = data.get('embedded_activity_config', {}).get('activity_premium_tier_level') - self.tags: List[str] = data.get('tags', []) - self.overlay: bool = data.get('overlay', False) - self.overlay_compatibility_hook: bool = data.get('overlay_compatibility_hook', False) +class ApplicationInstallParams: + """Represents an application's authorization parameters. - params = data.get('install_params') - self.custom_install_url: Optional[str] = data.get('custom_install_url') - self.install_params: Optional[ApplicationInstallParams] = ( - ApplicationInstallParams(self.id, params) if params else None - ) + .. container:: operations - self.public: bool = data.get( - 'integration_public', data.get('bot_public', True) - ) # The two seem to be used interchangeably? - self.require_code_grant: bool = data.get( - 'integration_require_code_grant', data.get('bot_require_code_grant', False) - ) # Same here + .. describe:: str(x) - def __repr__(self) -> str: - return f'<{self.__class__.__name__} id={self.id} name={self.name!r} description={self.description!r}>' + Returns the authorization URL. - @property - def icon(self) -> Optional[Asset]: - """Optional[:class:`.Asset`]: Retrieves the application's icon asset, if any.""" - if self._icon is None: - return None - return Asset._from_icon(self._state, self.id, self._icon, path='app') + .. versionadded:: 2.0 - @property - def cover_image(self) -> Optional[Asset]: - """Optional[:class:`.Asset`]: Retrieves the cover image on a store embed, if any. + Attributes + ---------- + application_id: :class:`int` + The ID of the application to be authorized. + scopes: List[:class:`str`] + The list of `OAuth2 scopes `_ + to add the application with. + permissions: :class:`Permissions` + The permissions to grant to the added bot. + """ - This is only available if the application is a game sold on Discord. - """ - if self._cover_image is None: - return None - return Asset._from_cover_image(self._state, self.id, self._cover_image) + __slots__ = ('application_id', 'scopes', 'permissions') + + def __init__( + self, application_id: int, *, scopes: Optional[List[str]] = None, permissions: Optional[Permissions] = None + ): + self.application_id: int = application_id + self.scopes: List[str] = scopes or ['bot', 'applications.commands'] + self.permissions: Permissions = permissions or Permissions(0) + + @classmethod + def from_application(cls, application: Snowflake, data: dict) -> ApplicationInstallParams: + return cls( + application.id, + scopes=data.get('scopes', []), + permissions=Permissions(int(data.get('permissions', 0))), + ) - @property - def splash(self) -> Optional[Asset]: - """Optional[:class:`.Asset`]: Retrieves the application's splash asset, if any.""" - if self._splash is None: - return None - return Asset._from_application_asset(self._state, self.id, self._splash) + def __repr__(self) -> str: + return f'' - @property - def flags(self) -> ApplicationFlags: - """:class:`ApplicationFlags`: The flags of this application.""" - return ApplicationFlags._from_value(self._flags) + def __str__(self) -> str: + return self.url @property - def install_url(self) -> Optional[str]: - """:class:`str`: The URL to install the application.""" - return self.custom_install_url or self.install_params.url if self.install_params else None + def url(self) -> str: + """:class:`str`: The URL to add the application with the parameters.""" + return utils.oauth_url(self.application_id, permissions=self.permissions, scopes=self.scopes) + def to_dict(self) -> dict: + return { + 'scopes': self.scopes, + 'permissions': self.permissions.value, + } -class Application(PartialApplication): - """Represents application info for an application you own. - .. versionadded:: 2.0 +class ApplicationAsset(AssetMixin, Hashable): + """Represents an application asset. .. container:: operations .. describe:: x == y - Checks if two applications are equal. + Checks if two assets are equal. .. describe:: x != y - Checks if two applications are not equal. + Checks if two assets are not equal. .. describe:: hash(x) - Return the application's hash. + Return the asset's hash. .. describe:: str(x) - Returns the application's name. + Returns the asset's name. + + .. versionadded:: 2.0 Attributes - ------------- - owner: :class:`abc.User` - The application owner. - team: Optional[:class:`Team`] - The application's team. - bot: Optional[:class:`ApplicationBot`] - The bot attached to the application, if any. - guild_id: Optional[:class:`int`] - The guild ID this application is linked to, if any. - primary_sku_id: Optional[:class:`int`] - The application's primary SKU ID, if any. - slug: Optional[:class:`str`] - If this application is a game sold on Discord, - this field will be the URL slug that links to the store page. - interactions_endpoint_url: Optional[:class:`str`] - The URL interactions will be sent to, if set. - redirect_uris: List[:class:`str`] - A list of redirect URIs authorized for this application. - verification_state: :class:`ApplicationVerificationState` - The verification state of the application. - store_application_state: :class:`StoreApplicationState` - The approval state of the commerce application. - rpc_application_state: :class:`RPCApplicationState` - The approval state of the RPC usage application. + ----------- + application: Union[:class:`PartialApplication`, :class:`IntegrationApplication`] + The application that the asset is for. + id: :class:`int` + The asset's ID. + name: :class:`str` + The asset's name. """ - __slots__ = ( - 'owner', - 'team', - 'guild_id', - 'primary_sku_id', - 'slug', - 'redirect_uris', - 'bot', - 'verification_state', - 'store_application_state', - 'rpc_application_state', - 'interactions_endpoint_url', - ) + __slots__ = ('_state', 'id', 'name', 'type', 'application') - def _update(self, data: AppInfoPayload) -> None: - super()._update(data) - from .team import Team + def __init__(self, *, data: AssetPayload, application: Union[PartialApplication, IntegrationApplication]) -> None: + self._state: ConnectionState = application._state + self.application = application + self.id: int = int(data['id']) + self.name: str = data['name'] + self.type: ApplicationAssetType = try_enum(ApplicationAssetType, data.get('type', 1)) - self.guild_id: Optional[int] = utils._get_as_snowflake(data, 'guild_id') - self.redirect_uris: List[str] = data.get('redirect_uris', []) - self.primary_sku_id: Optional[int] = utils._get_as_snowflake(data, 'primary_sku_id') - self.slug: Optional[str] = data.get('slug') - self.interactions_endpoint_url: Optional[str] = data.get('interactions_endpoint_url') + def __repr__(self) -> str: + return f'' - self.verification_state = try_enum(ApplicationVerificationState, data['verification_state']) - self.store_application_state = try_enum(StoreApplicationState, data.get('store_application_state', 1)) - self.rpc_application_state = try_enum(RPCApplicationState, data.get('rpc_application_state', 0)) + def __str__(self) -> str: + return self.name + + @classmethod + def _from_embedded_activity_config( + cls, application: Union[PartialApplication, IntegrationApplication], id: int + ) -> ApplicationAsset: + return cls(data={'id': id, 'name': '', 'type': 1}, application=application) + + @property + def animated(self) -> bool: + """:class:`bool`: Indicates if the asset is animated. Here for compatibility purposes.""" + return False + + @property + def url(self) -> str: + """:class:`str`: Returns the URL of the asset.""" + return f'{Asset.BASE}/app-assets/{self.application.id}/{self.id}.png' + + async def delete(self) -> None: + """|coro| + + Deletes the asset. + + Raises + ------ + Forbidden + You are not allowed to delete this asset. + HTTPException + Deleting the asset failed. + """ + await self._state.http.delete_asset(self.application.id, self.id) + +class ApplicationActivityStatistics: + """Represents an application's activity usage statistics for a particular user. + + .. versionadded:: 2.0 + + Attributes + ----------- + application_id: :class:`int` + The ID of the application. + user_id: :class:`int` + The ID of the user. + duration: :class:`int` + How long the user has ever played the game in seconds. + sku_duration: :class:`int` + How long the user has ever played the game on Discord in seconds. + updated_at: :class:`datetime.datetime` + When the user last played the game. + """ + + __slots__ = ('application_id', 'user_id', 'duration', 'sku_duration', 'updated_at', '_state') + + def __init__( + self, + *, + data: Union[ActivityStatisticsPayload, GlobalActivityStatisticsPayload], + state: ConnectionState, + application_id: Optional[int] = None, + ) -> None: + self._state = state + self.application_id = application_id or int(data['application_id']) # type: ignore + self.user_id: int = int(data['user_id']) if 'user_id' in data else state.self_id # type: ignore + self.duration: int = data.get('total_duration', data.get('duration', 0)) + self.sku_duration: int = data.get('total_discord_sku_duration', 0) + self.updated_at: datetime = utils.parse_time(data.get('last_played_at', data.get('updated_at'))) or utils.utcnow() + + def __repr__(self) -> str: + return f'' + + @property + def user(self) -> Optional[User]: + """Optional[:class:`User`]: Returns the user associated with the statistics, if available.""" + return self._state.get_user(self.user_id) + + async def application(self) -> PartialApplication: + """|coro| + + Returns the application associated with the statistics. + + Raises + ------ + HTTPException + Fetching the application failed. + """ state = self._state - team: Optional[TeamPayload] = data.get('team') - self.team: Optional[Team] = Team(state, team) if team else None + data = await state.http.get_partial_application(self.application_id) + return PartialApplication(state=state, data=data) - if bot := data.get('bot'): - bot['public'] = data.get('bot_public', self.public) - bot['require_code_grant'] = data.get('bot_require_code_grant', self.require_code_grant) - self.bot: Optional[ApplicationBot] = ApplicationBot(data=bot, state=state, application=self) if bot else None - owner = data.get('owner') - if owner is not None: - self.owner: abcUser = state.create_user(owner) +class ManifestLabel(Hashable): + """Represents an application manifest label. + + .. container:: operations + + .. describe:: x == y + + Checks if two manifest labels are equal. + + .. describe:: x != y + + Checks if two manifest labels are not equal. + + .. describe:: hash(x) + + Return the manifest label's hash. + + .. describe:: str(x) + + Returns the manifest label's name. + + .. versionadded:: 2.0 + + Attributes + ------------ + id: :class:`int` + The ID of the label. + application_id: :class:`int` + The ID of the application the label is for. + name: :class:`str` + The name of the label. + """ + + __slots__ = ('id', 'application_id', 'name') + + def __new__(cls, *, data: ManifestLabelPayload, application_id: Optional[int] = None) -> Union[ManifestLabel, int]: + if data.get('name') is None: + return int(data['id']) + if application_id is not None: + data['application_id'] = application_id + return super().__new__(cls) + + def __init__(self, *, data: ManifestLabelPayload, **kwargs) -> None: + self.id: int = int(data['id']) + self.application_id: int = int(data['application_id']) + self.name: Optional[str] = data.get('name') + + def __repr__(self) -> str: + return f'' + + +class Manifest(Hashable): + """Represents an application manifest. + + .. container:: operations + + .. describe:: x == y + + Checks if two manifests are equal. + + .. describe:: x != y + + Checks if two manifests are not equal. + + .. describe:: hash(x) + + Return the manifest's hash. + + .. versionadded:: 2.0 + + Attributes + ----------- + id: :class:`int` + The ID of the manifest. + application_id: :class:`int` + The ID of the application the manifest is for. + label_id: :class:`int` + The ID of the manifest's label. + label: Optional[:class:`ManifestLabel`] + The manifest's label, if available. + redistributable_label_ids: List[:class:`int`] + The label IDs of the manifest's redistributables, if available. + url: Optional[:class:`str`] + The URL of the manifest. + """ + + __slots__ = ('id', 'application_id', 'label_id', 'label', 'redistributable_label_ids', 'url', '_state') + + if TYPE_CHECKING: + label_id: int + label: Optional[ManifestLabel] + + def __init__(self, *, data: ManifestPayload, state: ConnectionState, application_id: int) -> None: + self._state = state + self.id: int = int(data['id']) + self.application_id = application_id + self.redistributable_label_ids: List[int] = [int(r) for r in data.get('redistributable_label_ids', [])] + self.url: Optional[str] = data.get('url') + + label = ManifestLabel(data=data['label'], application_id=application_id) + if isinstance(label, int): + self.label_id = label + self.label = None else: - self.owner: abcUser = state.user # type: ignore # state.user will always be present here + self.label_id = label.id + self.label = label def __repr__(self) -> str: - return ( - f'<{self.__class__.__name__} id={self.id} name={self.name!r} ' - f'description={self.description!r} public={self.public} ' - f'owner={self.owner!r}>' + return f'' + + async def upload(self, manifest: MetadataObject, /) -> None: + """|coro| + + Uploads the manifest object to the manifest. + + .. note:: + + This should only be used for builds with a status of :attr:`ApplicationBuildStatus.uploading`. + + Additionally, it requires that :attr:`url` is set to the uploadable URL + (populated on uploadable manifest objects returned from :meth:`ApplicationBranch.create_build`). + + Parameters + ----------- + manifest: :class:`Metadata` + A dict-like object representing the manifest to upload. + + Raises + ------- + ValueError + Upload URL is not set. + Forbidden + Upload URL invalid. + HTTPException + Uploading the manifest failed. + """ + if not self.url: + raise ValueError('Manifest URL is not set') + await self._state.http.upload_to_cloud(self.url, utils._to_json(dict(manifest))) + + +class ApplicationBuild(Hashable): + """Represents a build of an :class:`ApplicationBranch`. + + .. container:: operations + + .. describe:: x == y + + Checks if two builds are equal. + + .. describe:: x != y + + Checks if two builds are not equal. + + .. describe:: hash(x) + + Return the build's hash. + + .. versionadded:: 2.0 + + Attributes + ----------- + id: :class:`int` + The build ID. + application_id: :class:`int` + The ID of the application the build belongs to. + branch: :class:`ApplicationBranch` + The branch the build belongs to. + created_at: :class:`datetime.datetime` + When the build was created. + status: :class:`ApplicationBuildStatus` + The status of the build. + source_build_id: Optional[:class:`int`] + The ID of the source build, if any. + version: Optional[:class:`str`] + The version of the build, if any. + """ + + def __init__(self, *, data: BuildPayload, state: ConnectionState, branch: ApplicationBranch) -> None: + self._state = state + self.branch = branch + self._update(data) + + def _update(self, data: BuildPayload) -> None: + state = self._state + + self.id: int = int(data['id']) + self.application_id: int = self.branch.application_id + self.created_at: datetime = ( + utils.parse_time(data['created_at']) if 'created_at' in data else utils.snowflake_time(self.id) + ) + self.status: ApplicationBuildStatus = try_enum(ApplicationBuildStatus, data['status']) + self.source_build_id: Optional[int] = utils._get_as_snowflake(data, 'source_build_id') + self.version: Optional[str] = data.get('version') + self.manifests: List[Manifest] = [ + Manifest(data=m, state=state, application_id=self.application_id) for m in data.get('manifests', []) + ] + + def __repr__(self) -> str: + return f'' + + @staticmethod + def format_download_url( + endpoint: str, application_id, branch_id, build_id, manifest_id, user_id, expires: int, signature: str + ) -> str: + return f'{endpoint}/apps/{application_id}/builds/{build_id}/manifests/{manifest_id}/metadata/MANIFEST?branch_id={branch_id}&manifest_id={manifest_id}&user_id={user_id}&expires={expires}&signature={quote(signature)}' + + async def size(self, manifests: Collection[Snowflake] = MISSING) -> float: + """|coro| + + Retrieves the storage space used by the build. + + Parameters + ----------- + manifests: List[:class:`Manifest`] + The manifests to fetch the storage space for. + Defaults to all the build's manifests. + + Raises + ------- + HTTPException + Fetching the storage space failed. + + Returns + -------- + :class:`float` + The storage space used by the build in kilobytes. + """ + data = await self._state.http.get_branch_build_size( + self.application_id, self.branch.id, self.id, [m.id for m in manifests or self.manifests] + ) + return float(data['size_kb']) + + async def download_urls(self, manifest_labels: Collection[Snowflake] = MISSING) -> List[str]: + """|coro| + + Retrieves the download URLs of the build. + + These download URLs are for the manifest metadata, which can be used to download the artifacts. + + .. note:: + + The download URLs are signed and valid for roughly 7 days. + + Parameters + ----------- + manifest_labels: List[:class:`ManifestLabel`] + The manifest labels to fetch the download URLs for. + Defaults to all the build's manifest labels. + + Raises + ------- + NotFound + The build was not found or you are not entitled to it. + Forbidden + You are not allowed to manage this application. + HTTPException + Fetching the download URLs failed. + + Returns + -------- + List[:class:`str`] + The download URLs of the build. + """ + state = self._state + app_id, branch_id, build_id, user_id = self.application_id, self.branch.id, self.id, state.self_id + data = await state.http.get_branch_build_download_signatures( + app_id, + branch_id, + build_id, + [m.id for m in manifest_labels] if manifest_labels else list({m.label_id for m in self.manifests}), ) + return [ + self.format_download_url(v['endpoint'], app_id, branch_id, build_id, k, user_id, v['expires'], v['signature']) + for k, v in data.items() + ] + + async def edit(self, status: ApplicationBuildStatus) -> None: + """|coro| + + Edits the build. + + Parameters + ----------- + status: :class:`ApplicationBuildStatus` + The new status of the build. + + Raises + ------- + Forbidden + You are not allowed to manage this application. + HTTPException + Editing the build failed. + """ + await self._state.http.edit_build(self.application_id, self.id, str(status)) + self.status = try_enum(ApplicationBuildStatus, str(status)) + + async def upload_files(self, *files: File, hash: bool = True) -> None: + r"""|coro| + + Uploads files to the build. + + .. note:: + + This should only be used for builds with a status of :attr:`ApplicationBuildStatus.uploading`. + + .. warning:: + + This does not account for chunking large files. + + Parameters + ----------- + \*files: :class:`discord.File` + The files to upload. + hash: :class:`bool` + Whether to calculate the MD5 hash of the files before upload. + + Raises + ------- + Forbidden + You are not allowed to manage this application. + HTTPException + Uploading the files failed. + """ + if not files: + return + + urls = await self._state.http.get_build_upload_urls(self.application_id, self.id, files, hash) + id_files = {f.filename: f for f in files} + for url in urls: + file = id_files.get(url['id']) + if file: + await self._state.http.upload_to_cloud(url['url'], file, file.md5 if hash else None) + + async def publish(self) -> None: + """|coro| + + Publishes the build. + + This can only be done on builds with an :attr:`status` of :attr:`ApplicationBuildStatus.ready`. + + Raises + ------- + Forbidden + You are not allowed to manage this application. + HTTPException + Publishing the build failed. + """ + await self._state.http.publish_build(self.application_id, self.branch.id, self.id) + + async def delete(self) -> None: + """|coro| + + Deletes the build. + + Raises + ------- + Forbidden + You are not allowed to manage this application. + HTTPException + Deleting the build failed. + """ + await self._state.http.delete_build(self.application_id, self.id) + + +class ApplicationBranch(Hashable): + """Represents an application branch. + + .. container:: operations + + .. describe:: x == y + + Checks if two branches are equal. + + .. describe:: x != y + + Checks if two branches are not equal. + + .. describe:: hash(x) + + Return the branch's hash. + + .. describe:: str(x) + + Returns the branch's name. + + .. versionadded:: 2.0 + + Attributes + ----------- + id: :class:`int` + The branch ID. + application_id: :class:`int` + The ID of the application the branch belongs to. + live_build_id: Optional[:class:`int`] + The ID of the live build, if it exists and is provided. + name: :class:`str` + The branch name, if known. + """ + + __slots__ = ('id', 'live_build_id', 'name', 'application_id', '_created_at', '_state') + + def __init__(self, *, data: BranchPayload, state: ConnectionState, application_id: int) -> None: + self._state = state + self.application_id = application_id + + self.id: int = int(data['id']) + self.name: str = data['name'] if 'name' in data else ('master' if self.id == self.application_id else 'unknown') + self.live_build_id: Optional[int] = utils._get_as_snowflake(data, 'live_build_id') + self._created_at = data.get('created_at') + + def __repr__(self) -> str: + return f'' + + def __str__(self) -> str: + return self.name + + @property + def created_at(self) -> datetime: + """:class:`datetime.datetime`: Returns the branch's creation time in UTC. + + .. note:: + + This may be innacurate for the master branch if the data is not provided, + as the ID is shared with the application ID. + """ + return utils.parse_time(self._created_at) if self._created_at else utils.snowflake_time(self.id) + + def is_master(self) -> bool: + """:class:`bool`: Indicates if this is the master branch.""" + return self.id == self.application_id + + async def builds(self) -> List[ApplicationBuild]: + """|coro| + + Retrieves the builds of the branch. + + Raises + ------ + Forbidden + You are not allowed to manage this application. + HTTPException + Fetching the builds failed. + """ + data = await self._state.http.get_branch_builds(self.application_id, self.id) + return [ApplicationBuild(data=build, state=self._state, branch=self) for build in data] + + async def fetch_build(self, build_id: int, /) -> ApplicationBuild: + """|coro| + + Retrieves a build of the branch with the given ID. + + Parameters + ----------- + build_id: :class:`int` + The ID of the build to fetch. + + Raises + ------ + NotFound + The build does not exist. + Forbidden + You are not allowed to manage this application. + HTTPException + Fetching the build failed. + """ + data = await self._state.http.get_branch_build(self.application_id, self.id, build_id) + return ApplicationBuild(data=data, state=self._state, branch=self) + + async def fetch_live_build_id(self) -> Optional[int]: + """|coro| + + Retrieves and caches the ID of the live build of the branch. + + Raises + ------ + HTTPException + Fetching the build failed. + + Returns + -------- + Optional[:class:`int`] + The ID of the live build, if it exists. + """ + data = await self._state.http.get_build_ids((self.id,)) + if not data: + return + branch = data[0] + self.live_build_id = build_id = utils._get_as_snowflake(branch, 'live_build_id') + return build_id + + async def live_build(self, *, locale: Locale = MISSING, platform: str) -> ApplicationBuild: + """|coro| + + Retrieves the live build of the branch. + + Parameters + ----------- + locale: :class:`Locale` + The locale to fetch the build for. Defaults to the current user locale. + platform: :class:`str` + The platform to fetch the build for. + Usually one of ``win32``, ``win64``, ``macos``, or ``linux``. + + Raises + ------ + NotFound + The branch does not have a live build. + HTTPException + Fetching the build failed. + """ + state = self._state + data = await state.http.get_live_branch_build( + self.application_id, self.id, str(locale) if locale else state.locale, str(platform) + ) + self.live_build_id = int(data['id']) + return ApplicationBuild(data=data, state=self._state, branch=self) + + async def latest_build(self) -> ApplicationBuild: + """|coro| + + Retrieves the latest successful build of the branch. + + Raises + ------ + NotFound + The branch does not have a successful build. + Forbidden + You are not allowed to manage this application. + HTTPException + Fetching the build failed. + """ + data = await self._state.http.get_latest_branch_build(self.application_id, self.id) + return ApplicationBuild(data=data, state=self._state, branch=self) + + async def create_build( + self, + *, + built_with: str = "DISPATCH", + manifests: Sequence[MetadataObject], + source_build: Optional[Snowflake] = None, + ) -> Tuple[ApplicationBuild, List[Manifest]]: + """|coro| + + Creates a build for the branch. + + Parameters + ----------- + manifests: List[:class:`Metadata`] + A list of dict-like objects representing the manifests. + source_build: Optional[:class:`ApplicationBuild`] + The source build of the build, if any. + + Raises + ------ + Forbidden + You are not allowed to manage this application. + HTTPException + Creating the build failed. + + Returns + -------- + Tuple[:class:`ApplicationBuild`, List[:class:`Manifest`]] + The created build and manifest uploads. + """ + state = self._state + app_id = self.application_id + payload = {'built_with': built_with, 'manifests': [dict(m) for m in manifests]} + if source_build: + payload['source_build_id'] = source_build.id + + data = await state.http.create_branch_build(app_id, self.id, payload) + build = ApplicationBuild(data=data['build'], state=state, branch=self) + manifest_uploads = [Manifest(data=m, state=state, application_id=app_id) for m in data['manifest_uploads']] + return build, manifest_uploads + + async def promote(self, branch: Snowflake, /) -> None: + """|coro| + + Promotes this branch's live build to the given branch. + + Parameters + ----------- + branch: :class:`ApplicationBranch` + The target branch to promote the build to. + + Raises + ------ + Forbidden + You are not allowed to manage this application. + HTTPException + Promoting the branch failed. + """ + await self._state.http.promote_build(self.application_id, self.id, branch.id) + + async def delete(self) -> None: + """|coro| + + Deletes the branch. + + Raises + ------ + Forbidden + You are not allowed to manage this application. + HTTPException + Deleting the branch failed. + """ + await self._state.http.delete_app_branch(self.application_id, self.id) + + +class ApplicationTester(User): + """Represents a user whitelisted for an application. + + .. container:: operations + + .. describe:: x == y + + Checks if two testers are equal. + + .. describe:: x != y + + Checks if two testers are not equal. + + .. describe:: hash(x) + + Return the tester's hash. + + .. describe:: str(x) + + Returns the tester's name with discriminator. + + .. versionadded:: 2.0 + + Attributes + ------------- + application: :class:`Application` + The application the tester is whitelisted for. + state: :class:`ApplicationMembershipState` + The state of the tester (i.e. invited or accepted) + """ + + __slots__ = ('application', 'state') + + def __init__(self, application: Application, state: ConnectionState, data: WhitelistedUserPayload): + self.application: Application = application + self.state: ApplicationMembershipState = try_enum(ApplicationMembershipState, data['state']) + super().__init__(state=state, data=data['user']) + + def __repr__(self) -> str: + return ( + f'<{self.__class__.__name__} id={self.id} name={self.name!r} ' + f'discriminator={self.discriminator!r} state={self.state!r}>' + ) + + async def remove(self) -> None: + """|coro| + + Removes the user from the whitelist. + + Raises + ------- + HTTPException + Removing the user failed. + """ + await self._state.http.delete_app_whitelist(self.application.id, self.id) + + +class PartialApplication(Hashable): + """Represents a partial Application. + + .. container:: operations + + .. describe:: x == y + + Checks if two applications are equal. + + .. describe:: x != y + + Checks if two applications are not equal. + + .. describe:: hash(x) + + Return the application's hash. + + .. describe:: str(x) + + Returns the application's name. + + .. versionadded:: 2.0 + + Attributes + ------------- + id: :class:`int` + The application ID. + name: :class:`str` + The application name. + description: :class:`str` + The application description. + rpc_origins: List[:class:`str`] + A list of RPC origin URLs, if RPC is enabled. + verify_key: :class:`str` + The hex encoded key for verification in interactions and the + GameSDK's `GetTicket `_. + terms_of_service_url: Optional[:class:`str`] + The application's terms of service URL, if set. + privacy_policy_url: Optional[:class:`str`] + The application's privacy policy URL, if set. + public: :class:`bool` + Whether the integration can be invited by anyone or if it is locked + to the application owner. + require_code_grant: :class:`bool` + Whether the integration requires the completion of the full OAuth2 code + grant flow to join + max_participants: Optional[:class:`int`] + The max number of people that can participate in the activity. + Only available for embedded activities. + type: Optional[:class:`ApplicationType`] + The type of application. + tags: List[:class:`str`] + A list of tags that describe the application. + overlay: :class:`bool` + Whether the application has a Discord overlay or not. + guild_id: Optional[:class:`int`] + The ID of the guild the application is attached to, if any. + primary_sku_id: Optional[:class:`int`] + The application's primary SKU ID, if any. + This can be an application's game SKU, subscription SKU, etc. + store_listing_sku_id: Optional[:class:`int`] + The application's store listing SKU ID, if any. + If exists, this SKU ID should be used for checks. + slug: Optional[:class:`str`] + The slug for the application's primary SKU, if any. + eula_id: Optional[:class:`int`] + The ID of the EULA for the application, if any. + aliases: List[:class:`str`] + A list of aliases that can be used to identify the application. + developers: List[:class:`Company`] + A list of developers that developed the application. + publishers: List[:class:`Company`] + A list of publishers that published the application. + executables: List[:class:`ApplicationExecutable`] + A list of executables that are the application's. + third_party_skus: List[:class:`ThirdPartySKU`] + A list of third party platforms the SKU is available at. + custom_install_url: Optional[:class:`str`] + The custom URL to use for authorizing the application, if specified. + install_params: Optional[:class:`ApplicationInstallParams`] + The parameters to use for authorizing the application, if specified. + embedded_activity_config: Optional[:class:`EmbeddedActivityConfig`] + The application's embedded activity configuration, if any. + owner: Optional[:class:`User`] + The application owner. This may be a team user account. + + .. note:: + + In almost all cases, this is not available for partial applications. + team: Optional[:class:`Team`] + The team that owns the application. + + .. note:: + + In almost all cases, this is not available. + """ + + __slots__ = ( + '_state', + 'id', + 'name', + 'description', + 'rpc_origins', + 'verify_key', + 'terms_of_service_url', + 'privacy_policy_url', + '_icon', + '_flags', + '_cover_image', + '_splash', + 'public', + 'require_code_grant', + 'type', + 'hook', + 'tags', + 'max_participants', + 'overlay', + 'overlay_warn', + 'overlay_compatibility_hook', + 'aliases', + 'developers', + 'publishers', + 'executables', + 'third_party_skus', + 'custom_install_url', + 'install_params', + 'embedded_activity_config', + 'guild_id', + 'primary_sku_id', + 'store_listing_sku_id', + 'slug', + 'eula_id', + 'owner', + 'team', + '_guild', + ) + + if TYPE_CHECKING: + owner: Optional[User] + team: Optional[Team] + + def __init__(self, *, state: ConnectionState, data: PartialApplicationPayload): + self._state: ConnectionState = state + self._update(data) + + def __str__(self) -> str: + return self.name + + def _update(self, data: PartialApplicationPayload) -> None: + state = self._state + + self.id: int = int(data['id']) + self.name: str = data['name'] + self.description: str = data['description'] + self.rpc_origins: Optional[List[str]] = data.get('rpc_origins') or [] + self.verify_key: str = data['verify_key'] + + self.aliases: List[str] = data.get('aliases', []) + self.developers: List[Company] = [Company(data=d) for d in data.get('developers', [])] + self.publishers: List[Company] = [Company(data=d) for d in data.get('publishers', [])] + self.executables: List[ApplicationExecutable] = [ + ApplicationExecutable(data=e, application=self) for e in data.get('executables', []) + ] + self.third_party_skus: List[ThirdPartySKU] = [ + ThirdPartySKU(data=t, application=self) for t in data.get('third_party_skus', []) + ] + + self._icon: Optional[str] = data.get('icon') + self._cover_image: Optional[str] = data.get('cover_image') + self._splash: Optional[str] = data.get('splash') + + self.terms_of_service_url: Optional[str] = data.get('terms_of_service_url') + self.privacy_policy_url: Optional[str] = data.get('privacy_policy_url') + self._flags: int = data.get('flags', 0) + self.type: Optional[ApplicationType] = try_enum(ApplicationType, data['type']) if data.get('type') else None + self.hook: bool = data.get('hook', False) + self.max_participants: Optional[int] = data.get('max_participants') + self.tags: List[str] = data.get('tags', []) + self.overlay: bool = data.get('overlay', False) + self.overlay_warn: bool = data.get('overlay_warn', False) + self.overlay_compatibility_hook: bool = data.get('overlay_compatibility_hook', False) + self.guild_id: Optional[int] = utils._get_as_snowflake(data, 'guild_id') + self.primary_sku_id: Optional[int] = utils._get_as_snowflake(data, 'primary_sku_id') + self.store_listing_sku_id: Optional[int] = utils._get_as_snowflake(data, 'store_listing_sku_id') + self.slug: Optional[str] = data.get('slug') + self.eula_id: Optional[int] = utils._get_as_snowflake(data, 'eula_id') + + params = data.get('install_params') + self.custom_install_url: Optional[str] = data.get('custom_install_url') + self.install_params: Optional[ApplicationInstallParams] = ( + ApplicationInstallParams.from_application(self, params) if params else None + ) + self.embedded_activity_config: Optional[EmbeddedActivityConfig] = ( + EmbeddedActivityConfig(data=data['embedded_activity_config'], application=self) + if 'embedded_activity_config' in data + else None + ) + + self.public: bool = data.get( + 'integration_public', data.get('bot_public', True) + ) # The two seem to be used interchangeably? + self.require_code_grant: bool = data.get( + 'integration_require_code_grant', data.get('bot_require_code_grant', False) + ) # Same here + + # Hacky, but I want these to be persisted + + existing = getattr(self, 'owner', None) + owner = data.get('owner') + self.owner = state.create_user(owner) if owner else existing + + existing = getattr(self, 'team', None) + team = data.get('team') + self.team = Team(state=state, data=team) if team else existing + + if self.team and not self.owner: + # We can create a team user from the team data + team = self.team + payload: PartialUserPayload = { + 'id': team.id, + 'username': f'team{team.id}', + 'public_flags': UserFlags.team_user.value, + 'discriminator': '0000', + 'avatar': None, + } + self.owner = state.create_user(payload) + + self._guild: Optional[Guild] = None + if 'guild' in data: + from .guild import Guild + + self._guild = Guild(state=state, data=data['guild']) + + def __repr__(self) -> str: + return f'<{self.__class__.__name__} id={self.id} name={self.name!r} description={self.description!r}>' + + @property + def icon(self) -> Optional[Asset]: + """Optional[:class:`Asset`]: Retrieves the application's icon asset, if any.""" + if self._icon is None: + return None + return Asset._from_icon(self._state, self.id, self._icon, path='app') + + @property + def cover_image(self) -> Optional[Asset]: + """Optional[:class:`Asset`]: Retrieves the application's cover image, if any.""" + if self._cover_image is None: + return None + return Asset._from_cover_image(self._state, self.id, self._cover_image) + + @property + def splash(self) -> Optional[Asset]: + """Optional[:class:`Asset`]: Retrieves the application's splash asset, if any.""" + if self._splash is None: + return None + return Asset._from_application_asset(self._state, self.id, self._splash) + + @property + def flags(self) -> ApplicationFlags: + """:class:`ApplicationFlags`: The flags of this application.""" + return ApplicationFlags._from_value(self._flags) + + @property + def install_url(self) -> Optional[str]: + """:class:`str`: The URL to install the application.""" + return self.custom_install_url or self.install_params.url if self.install_params else None + + @property + def primary_sku_url(self) -> Optional[str]: + """:class:`str`: The URL to the primary SKU of the application, if any.""" + if self.primary_sku_id: + return f'https://discord.com/store/skus/{self.primary_sku_id}/{self.slug or "unknown"}' + + @property + def store_listing_sku_url(self) -> Optional[str]: + """:class:`str`: The URL to the store listing SKU of the application, if any.""" + if self.store_listing_sku_id: + return f'https://discord.com/store/skus/{self.store_listing_sku_id}/{self.slug or "unknown"}' + + @property + def guild(self) -> Optional[Guild]: + """Optional[:class:`Guild`]: The guild linked to the application, if any and available.""" + return self._state._get_guild(self.guild_id) or self._guild + + async def assets(self) -> List[ApplicationAsset]: + """|coro| + + Retrieves the assets of this application. + + Raises + ------ + HTTPException + Retrieving the assets failed. + + Returns + ------- + List[:class:`ApplicationAsset`] + The application's assets. + """ + data = await self._state.http.get_app_assets(self.id) + return [ApplicationAsset(data=d, application=self) for d in data] + + async def published_store_listings(self, *, localize: bool = True) -> List[StoreListing]: + """|coro| + + Retrieves all published store listings for this application. + + Parameters + ---------- + localize: :class:`bool` + Whether to localize the store listings to the current user's locale. + If ``False`` then all localizations are returned. + + Raises + ------- + HTTPException + Retrieving the listings failed. + + Returns + ------- + List[:class:`StoreListing`] + The store listings. + """ + state = self._state + data = await state.http.get_app_store_listings(self.id, country_code=state.country_code or 'US', localize=localize) + return [StoreListing(state=state, data=d, application=self) for d in data] + + async def primary_store_listing(self, *, localize: bool = True) -> StoreListing: + """|coro| + + Retrieves the primary store listing of this application. + + This is the public store listing of the primary SKU. + + Parameters + ----------- + localize: :class:`bool` + Whether to localize the store listing to the current user's locale. + If ``False`` then all localizations are returned. + + Raises + ------ + NotFound + The application does not have a primary SKU. + HTTPException + Retrieving the store listing failed. + + Returns + ------- + :class:`StoreListing` + The application's primary store listing, if any. + """ + state = self._state + data = await state.http.get_app_store_listing(self.id, country_code=state.country_code or 'US', localize=localize) + return StoreListing(state=state, data=data, application=self) + + async def achievements(self, completed: bool = True) -> List[Achievement]: + """|coro| + + Retrieves the achievements for this application. + + Parameters + ----------- + completed: :class:`bool` + Whether to only include achievements the user has completed or can access. + This means secret achievements that are not yet unlocked will not be included. + + If ``False``, then you require access to the application. + + Raises + ------- + Forbidden + You do not have permissions to fetch achievements. + HTTPException + Fetching the achievements failed. + + Returns + -------- + List[:class:`Achievement`] + The achievements retrieved. + """ + state = self._state + data = (await state.http.get_my_achievements(self.id)) if completed else (await state.http.get_achievements(self.id)) + return [Achievement(data=achievement, state=state) for achievement in data] + + async def entitlements(self, *, exclude_consumed: bool = True) -> List[Entitlement]: + """|coro| + + Retrieves the entitlements this account has granted for this application. + + Parameters + ----------- + exclude_consumed: :class:`bool` + Whether to exclude consumed entitlements. + + Raises + ------- + HTTPException + Fetching the entitlements failed. + + Returns + -------- + List[:class:`Entitlement`] + The entitlements retrieved. + """ + state = self._state + data = await state.http.get_user_app_entitlements(self.id, exclude_consumed=exclude_consumed) + return [Entitlement(data=entitlement, state=state) for entitlement in data] + + async def eula(self) -> Optional[EULA]: + """|coro| + + Retrieves the EULA for this application. + + Raises + ------- + HTTPException + Retrieving the EULA failed. + + Returns + -------- + Optional[:class:`EULA`] + The EULA retrieved, if any. + """ + if self.eula_id is None: + return None + + state = self._state + data = await state.http.get_eula(self.eula_id) + return EULA(data=data) + + async def ticket(self) -> str: + """|coro| + + Retrieves the license ticket for this application. + + Raises + ------- + HTTPException + Retrieving the ticket failed. + + Returns + -------- + :class:`str` + The ticket retrieved. + """ + state = self._state + data = await state.http.get_app_ticket(self.id) + return data['ticket'] + + async def entitlement_ticket(self) -> str: + """|coro| + + Retrieves the entitlement ticket for this application. + + Raises + ------- + HTTPException + Retrieving the ticket failed. + + Returns + -------- + :class:`str` + The ticket retrieved. + """ + state = self._state + data = await state.http.get_app_entitlement_ticket(self.id) + return data['ticket'] + + async def activity_statistics(self) -> List[ApplicationActivityStatistics]: + """|coro| + + Retrieves the activity usage statistics for this application. + + Raises + ------- + HTTPException + Retrieving the statistics failed. + + Returns + -------- + List[:class:`ApplicationActivityStatistics`] + The statistics retrieved. + """ + state = self._state + app_id = self.id + data = await state.http.get_app_activity_statistics(app_id) + return [ApplicationActivityStatistics(data=activity, state=state, application_id=app_id) for activity in data] + + +class Application(PartialApplication): + """Represents application info for an application you own. + + .. container:: operations + + .. describe:: x == y + + Checks if two applications are equal. + + .. describe:: x != y + + Checks if two applications are not equal. + + .. describe:: hash(x) + + Return the application's hash. + + .. describe:: str(x) + + Returns the application's name. + + .. versionadded:: 2.0 + + Attributes + ------------- + owner: :class:`User` + The application owner. This may be a team user account. + bot: Optional[:class:`ApplicationBot`] + The bot attached to the application, if any. + interactions_endpoint_url: Optional[:class:`str`] + The URL interactions will be sent to, if set. + role_connections_verification_url: Optional[:class:`str`] + The application's connection verification URL which will render the application as + a verification method in the guild's role verification configuration. + redirect_uris: List[:class:`str`] + A list of redirect URIs authorized for this application. + verification_state: :class:`ApplicationVerificationState` + The verification state of the application. + store_application_state: :class:`StoreApplicationState` + The approval state of the commerce application. + rpc_application_state: :class:`RPCApplicationState` + The approval state of the RPC usage application. + discoverability_state: :class:`ApplicationDiscoverabilityState` + The discoverability (app directory) state of the application. + """ + + __slots__ = ( + 'owner', + 'redirect_uris', + 'interactions_endpoint_url', + 'role_connections_verification_url', + 'bot', + 'verification_state', + 'store_application_state', + 'rpc_application_state', + 'discoverability_state', + '_discovery_eligibility_flags', + ) + + if TYPE_CHECKING: + owner: User + + def __init__(self, *, state: ConnectionState, data: ApplicationPayload, team: Optional[Team] = None): + self.team = team + super().__init__(state=state, data=data) + + def _update(self, data: ApplicationPayload) -> None: + super()._update(data) + + self.redirect_uris: List[str] = data.get('redirect_uris', []) + self.interactions_endpoint_url: Optional[str] = data.get('interactions_endpoint_url') + self.role_connections_verification_url: Optional[str] = data.get('role_connections_verification_url') + + self.verification_state = try_enum(ApplicationVerificationState, data['verification_state']) + self.store_application_state = try_enum(StoreApplicationState, data.get('store_application_state', 1)) + self.rpc_application_state = try_enum(RPCApplicationState, data.get('rpc_application_state', 0)) + self.discoverability_state = try_enum(ApplicationDiscoverabilityState, data.get('discoverability_state', 1)) + self._discovery_eligibility_flags = data.get('discovery_eligibility_flags', 0) + + state = self._state + + # Hacky, but I want these to be persisted + existing = getattr(self, 'bot', None) + bot = data.get('bot') + if bot: + bot['public'] = data.get('bot_public', self.public) # type: ignore + bot['require_code_grant'] = data.get('bot_require_code_grant', self.require_code_grant) # type: ignore + if existing is not None: + existing._update(bot) + else: + self.bot: Optional[ApplicationBot] = ApplicationBot(data=bot, state=state, application=self) if bot else None + + self.owner = self.owner or state.user + + def __repr__(self) -> str: + return ( + f'<{self.__class__.__name__} id={self.id} name={self.name!r} ' + f'description={self.description!r} public={self.public} ' + f'owner={self.owner!r}>' + ) + + @property + def discovery_eligibility_flags(self) -> ApplicationDiscoveryFlags: + """:class:`ApplicationDiscoveryFlags`: The discovery (app directory) eligibility flags for this application.""" + return ApplicationDiscoveryFlags._from_value(self._discovery_eligibility_flags) + + async def edit( + self, + *, + name: str = MISSING, + description: Optional[str] = MISSING, + icon: Optional[bytes] = MISSING, + cover_image: Optional[bytes] = MISSING, + tags: Sequence[str] = MISSING, + terms_of_service_url: Optional[str] = MISSING, + privacy_policy_url: Optional[str] = MISSING, + interactions_endpoint_url: Optional[str] = MISSING, + redirect_uris: Sequence[str] = MISSING, + rpc_origins: Sequence[str] = MISSING, + public: bool = MISSING, + require_code_grant: bool = MISSING, + flags: ApplicationFlags = MISSING, + custom_install_url: Optional[str] = MISSING, + install_params: Optional[ApplicationInstallParams] = MISSING, + developers: Sequence[Snowflake] = MISSING, + publishers: Sequence[Snowflake] = MISSING, + guild: Snowflake = MISSING, + team: Snowflake = MISSING, + ) -> None: + """|coro| + + Edits the application. + + All parameters are optional. + + Parameters + ----------- + name: :class:`str` + The name of the application. + description: :class:`str` + The description of the application. + icon: Optional[:class:`bytes`] + The icon of the application. + cover_image: Optional[:class:`bytes`] + The cover image of the application. + tags: List[:class:`str`] + A list of tags that describe the application. + terms_of_service_url: Optional[:class:`str`] + The URL to the terms of service of the application. + privacy_policy_url: Optional[:class:`str`] + The URL to the privacy policy of the application. + interactions_endpoint_url: Optional[:class:`str`] + The URL interactions will be sent to, if set. + redirect_uris: List[:class:`str`] + A list of redirect URIs authorized for this application. + rpc_origins: List[:class:`str`] + A list of RPC origins authorized for this application. + public: :class:`bool` + Whether the application is public or not. + require_code_grant: :class:`bool` + Whether the application requires a code grant or not. + flags: :class:`ApplicationFlags` + The flags of the application. + developers: List[:class:`Company`] + A list of companies that are the developers of the application. + publishers: List[:class:`Company`] + A list of companies that are the publishers of the application. + guild: :class:`Guild` + The guild to transfer the application to. + team: :class:`Team` + The team to transfer the application to. + + Raises + ------- + Forbidden + You do not have permissions to edit this application. + HTTPException + Editing the application failed. + """ + payload = {} + if name is not MISSING: + payload['name'] = name or '' + if description is not MISSING: + payload['description'] = description or '' + if icon is not MISSING: + if icon is not None: + payload['icon'] = utils._bytes_to_base64_data(icon) + else: + payload['icon'] = '' + if cover_image is not MISSING: + if cover_image is not None: + payload['cover_image'] = utils._bytes_to_base64_data(cover_image) + else: + payload['cover_image'] = '' + if tags is not MISSING: + payload['tags'] = tags or [] + if terms_of_service_url is not MISSING: + payload['terms_of_service_url'] = terms_of_service_url or '' + if privacy_policy_url is not MISSING: + payload['privacy_policy_url'] = privacy_policy_url or '' + if interactions_endpoint_url is not MISSING: + payload['interactions_endpoint_url'] = interactions_endpoint_url or '' + if redirect_uris is not MISSING: + payload['redirect_uris'] = redirect_uris + if rpc_origins is not MISSING: + payload['rpc_origins'] = rpc_origins + if public is not MISSING: + if self.bot: + payload['bot_public'] = public + else: + payload['integration_public'] = public + if require_code_grant is not MISSING: + if self.bot: + payload['bot_require_code_grant'] = require_code_grant + else: + payload['integration_require_code_grant'] = require_code_grant + if flags is not MISSING: + payload['flags'] = flags.value + if custom_install_url is not MISSING: + payload['custom_install_url'] = custom_install_url or '' + if install_params is not MISSING: + payload['install_params'] = install_params.to_dict() if install_params else None + if developers is not MISSING: + payload['developer_ids'] = [developer.id for developer in developers or []] + if publishers is not MISSING: + payload['publisher_ids'] = [publisher.id for publisher in publishers or []] + if guild: + payload['guild_id'] = guild.id + + if team: + await self._state.http.transfer_application(self.id, team.id) + + data = await self._state.http.edit_application(self.id, payload) + + self._update(data) + + async def fetch_bot(self) -> ApplicationBot: + """|coro| + + Retrieves the bot attached to this application. + + Raises + ------ + Forbidden + You do not have permissions to fetch the bot, + or the application does not have a bot. + HTTPException + Fetching the bot failed. + + Returns + ------- + :class:`ApplicationBot` + The bot attached to this application. + """ + data = await self._state.http.edit_bot(self.id, {}) + data['public'] = self.public # type: ignore + data['require_code_grant'] = self.require_code_grant # type: ignore + + if not self.bot: + self.bot = ApplicationBot(data=data, state=self._state, application=self) + else: + self.bot._update(data) + + return self.bot + + async def create_bot(self) -> ApplicationBot: + """|coro| + + Creates a bot attached to this application. + + Raises + ------ + Forbidden + You do not have permissions to create bots. + HTTPException + Creating the bot failed. + + Returns + ------- + :class:`ApplicationBot` + The bot that was created. + """ + state = self._state + await state.http.botify_app(self.id) + + # The endpoint no longer returns the bot so we fetch ourselves + # This is fine, the dev portal does the same + data = await state.http.get_my_application(self.id) + self._update(data) + return self.bot # type: ignore + + async def whitelisted(self) -> List[ApplicationTester]: + """|coro| + + Retrieves the list of whitelisted users (testers) for this application. + + Raises + ------ + Forbidden + You do not have permissions to fetch the testers. + HTTPException + Fetching the testers failed. + + Returns + ------- + List[:class:`ApplicationTester`] + The testers for this application. + """ + state = self._state + data = await state.http.get_app_whitelisted(self.id) + return [ApplicationTester(self, state, user) for user in data] + + @overload + async def whitelist(self, user: _UserTag, /) -> ApplicationTester: + ... + + @overload + async def whitelist(self, user: str, /) -> ApplicationTester: + ... + + @overload + async def whitelist(self, username: str, discriminator: str, /) -> ApplicationTester: + ... + + async def whitelist(self, *args: Union[_UserTag, str]) -> ApplicationTester: + """|coro| + + Whitelists a user (adds a tester) for this application. + + This function can be used in multiple ways. + + .. code-block:: python + + # Passing a user object: + await app.whitelist(user) + + # Passing a stringified user: + await app.whitelist('Jake#0001') + + # Passing a username and discriminator: + await app.whitelist('Jake', '0001') + + Parameters + ----------- + user: Union[:class:`User`, :class:`str`] + The user to whitelist. + username: :class:`str` + The username of the user to whitelist. + discriminator: :class:`str` + The discriminator of the user to whitelist. + + Raises + ------- + HTTPException + Inviting the user failed. + TypeError + More than 2 parameters or less than 1 parameter were passed. + + Returns + ------- + :class:`ApplicationTester` + The new whitelisted user. + """ + username: str + discrim: str + if len(args) == 1: + user = args[0] + if isinstance(user, _UserTag): + user = str(user) + username, discrim = user.split('#') + elif len(args) == 2: + username, discrim = args # type: ignore + else: + raise TypeError(f'invite_member() takes 1 or 2 arguments but {len(args)} were given') + + state = self._state + data = await state.http.invite_team_member(self.id, username, discrim) + return ApplicationTester(self, state, data) + + async def create_asset( + self, name: str, image: bytes, *, type: ApplicationAssetType = ApplicationAssetType.one + ) -> ApplicationAsset: + """|coro| + + Uploads an asset to this application. + + Parameters + ----------- + name: :class:`str` + The name of the asset. + image: :class:`bytes` + The image of the asset. Cannot be animated. + + Raises + ------- + Forbidden + You do not have permissions to upload assets. + HTTPException + Uploading the asset failed. + + Returns + -------- + :class:`ApplicationAsset` + The created asset. + """ + data = await self._state.http.create_asset(self.id, name, int(type), utils._bytes_to_base64_data(image)) + return ApplicationAsset(data=data, application=self) + + async def store_assets(self) -> List[StoreAsset]: + """|coro| + + Retrieves the store assets for this application. + + Raises + ------- + Forbidden + You do not have permissions to store assets. + HTTPException + Storing the assets failed. + + Returns + -------- + List[:class:`StoreAsset`] + The store assets retrieved. + """ + state = self._state + data = await self._state.http.get_store_assets(self.id) + return [StoreAsset(data=asset, state=state, parent=self) for asset in data] + + async def create_store_asset(self, file: File, /) -> StoreAsset: + """|coro| + + Uploads a store asset to this application. + + Parameters + ----------- + file: :class:`File` + The file to upload. Must be a PNG, JPG, GIF, or MP4. + + Raises + ------- + Forbidden + You do not have permissions to upload assets. + HTTPException + Uploading the asset failed. + + Returns + -------- + :class:`StoreAsset` + The created asset. + """ + state = self._state + data = await state.http.create_store_asset(self.id, file) + return StoreAsset(state=state, data=data, parent=self) + + async def skus(self, *, with_bundled_skus: bool = True, localize: bool = True) -> List[SKU]: + """|coro| + + Retrieves the SKUs for this application. + + Parameters + ----------- + with_bundled_skus: :class:`bool` + Whether to include bundled SKUs in the response. + localize: :class:`bool` + Whether to localize the SKU name and description to the current user's locale. + If ``False`` then all localizations are returned. + + Raises + ------- + Forbidden + You do not have permissions to fetch SKUs. + HTTPException + Fetching the SKUs failed. + + Returns + -------- + List[:class:`SKU`] + The SKUs retrieved. + """ + state = self._state + data = await self._state.http.get_app_skus( + self.id, country_code=state.country_code or 'US', with_bundled_skus=with_bundled_skus, localize=localize + ) + return [SKU(data=sku, state=state, application=self) for sku in data] + + async def primary_sku(self, *, localize: bool = True) -> Optional[SKU]: + """|coro| + + Retrieves the primary SKU for this application if it exists. + + Parameters + ----------- + localize: :class:`bool` + Whether to localize the SKU name and description to the current user's locale. + If ``False`` then all localizations are returned. + + Raises + ------- + Forbidden + You do not have permissions to fetch SKUs. + HTTPException + Fetching the SKUs failed. + + Returns + -------- + Optional[:class:`SKU`] + The primary SKU retrieved. + """ + if not self.primary_sku_id: + return None + + state = self._state + data = await self._state.http.get_sku( + self.primary_sku_id, country_code=state.country_code or 'US', localize=localize + ) + return SKU(data=data, state=state, application=self) + + async def store_listing_sku(self, *, localize: bool = True) -> Optional[SKU]: + """|coro| + + Retrieves the store listing SKU for this application if it exists. + + Parameters + ----------- + localize: :class:`bool` + Whether to localize the SKU name and description to the current user's locale. + If ``False`` then all localizations are returned. + + Raises + ------- + Forbidden + You do not have permissions to fetch SKUs. + HTTPException + Fetching the SKUs failed. + + Returns + -------- + Optional[:class:`SKU`] + The store listing SKU retrieved. + """ + if not self.store_listing_sku_id: + return None + + state = self._state + data = await self._state.http.get_sku( + self.store_listing_sku_id, country_code=state.country_code or 'US', localize=localize + ) + return SKU(data=data, state=state, application=self) + + async def create_sku( + self, + *, + name: str, + name_localizations: Optional[Mapping[Locale, str]] = None, + legal_notice: Optional[str] = None, + legal_notice_localizations: Optional[Mapping[Locale, str]] = None, + type: SKUType, + price_tier: Optional[int] = None, + price_overrides: Optional[Mapping[str, int]] = None, + sale_price_tier: Optional[int] = None, + sale_price_overrides: Optional[Mapping[str, int]] = None, + dependent_sku: Optional[Snowflake] = None, + access_level: Optional[SKUAccessLevel] = None, + features: Optional[Collection[SKUFeature]] = None, + locales: Optional[Collection[Locale]] = None, + genres: Optional[Collection[SKUGenre]] = None, + content_ratings: Optional[Collection[ContentRating]] = None, + system_requirements: Optional[Collection[SystemRequirements]] = None, + release_date: Optional[date] = None, + bundled_skus: Optional[Sequence[Snowflake]] = None, + manifest_labels: Optional[Sequence[Snowflake]] = None, + ): + """|coro| + + Creates a SKU for this application. + + Parameters + ----------- + name: :class:`str` + The SKU's name. + name_localizations: Optional[Mapping[:class:`Locale`, :class:`str`]] + The SKU's name localized to other languages. + legal_notice: Optional[:class:`str`] + The SKU's legal notice. + legal_notice_localizations: Optional[Mapping[:class:`Locale`, :class:`str`]] + The SKU's legal notice localized to other languages. + type: :class:`SKUType` + The SKU's type. + price_tier: Optional[:class:`int`] + The price tier of the SKU. + This is the base price in USD that other currencies will be calculated from. + price_overrides: Optional[Mapping[:class:`str`, :class:`int`]] + A mapping of currency to price. These prices override the base price tier. + sale_price_tier: Optional[:class:`int`] + The sale price tier of the SKU. + This is the base sale price in USD that other currencies will be calculated from. + sale_price_overrides: Optional[Mapping[:class:`str`, :class:`int`]] + A mapping of currency to sale price. These prices override the base sale price tier. + dependent_sku: Optional[:class:`int`] + The ID of the SKU that this SKU is dependent on. + access_level: Optional[:class:`SKUAccessLevel`] + The access level of the SKU. + features: Optional[List[:class:`SKUFeature`]] + A list of features of the SKU. + locales: Optional[List[:class:`Locale`]] + A list of locales supported by the SKU. + genres: Optional[List[:class:`SKUGenre`]] + A list of genres of the SKU. + content_ratings: Optional[List[:class:`ContentRating`]] + A list of content ratings of the SKU. + system_requirements: Optional[List[:class:`SystemRequirements`]] + A list of system requirements of the SKU. + release_date: Optional[:class:`datetime.date`] + The release date of the SKU. + bundled_skus: Optional[List[:class:`SKU`]] + A list SKUs that are bundled with this SKU. + manifest_labels: Optional[List[:class:`ManifestLabel`]] + A list of manifest labels for the SKU. + + Raises + ------- + Forbidden + You do not have permissions to create SKUs. + HTTPException + Creating the SKU failed. + + Returns + -------- + :class:`SKU` + The SKU created. + """ + payload = { + 'type': int(type), + 'name': {'default': name, 'localizations': {str(k): v for k, v in (name_localizations or {}).items()}}, + 'application_id': self.id, + } + if legal_notice or legal_notice_localizations: + payload['legal_notice'] = { + 'default': legal_notice, + 'localizations': {str(k): v for k, v in (legal_notice_localizations or {}).items()}, + } + if price_tier is not None: + payload['price_tier'] = price_tier + if price_overrides: + payload['price'] = {str(k): v for k, v in price_overrides.items()} + if sale_price_tier is not None: + payload['sale_price_tier'] = sale_price_tier + if sale_price_overrides: + payload['sale_price'] = {str(k): v for k, v in sale_price_overrides.items()} + if dependent_sku is not None: + payload['dependent_sku_id'] = dependent_sku.id + if access_level is not None: + payload['access_level'] = int(access_level) + if locales: + payload['locales'] = [str(l) for l in locales] + if features: + payload['features'] = [int(f) for f in features] + if genres: + payload['genres'] = [int(g) for g in genres] + if content_ratings: + payload['content_ratings'] = { + content_rating.agency: content_rating.to_dict() for content_rating in content_ratings + } + if system_requirements: + payload['system_requirements'] = { + system_requirement.os: system_requirement.to_dict() for system_requirement in system_requirements + } + if release_date is not None: + payload['release_date'] = release_date.isoformat() + if bundled_skus: + payload['bundled_skus'] = [s.id for s in bundled_skus] + if manifest_labels: + payload['manifest_labels'] = [m.id for m in manifest_labels] + + state = self._state + data = await state.http.create_sku(payload) + return SKU(data=data, state=state, application=self) + + async def fetch_achievement(self, achievement_id: int) -> Achievement: + """|coro| + + Retrieves an achievement for this application. + + Parameters + ----------- + achievement_id: :class:`int` + The ID of the achievement to fetch. + + Raises + ------ + Forbidden + You do not have permissions to fetch the achievement. + HTTPException + Fetching the achievement failed. + + Returns + ------- + :class:`Achievement` + The achievement retrieved. + """ + data = await self._state.http.get_achievement(self.id, achievement_id) + return Achievement(data=data, state=self._state) + + async def create_achievement( + self, + *, + name: str, + name_localizations: Optional[Mapping[Locale, str]] = None, + description: str, + description_localizations: Optional[Mapping[Locale, str]] = None, + icon: bytes, + secure: bool = False, + secret: bool = False, + ) -> Achievement: + """|coro| + + Creates an achievement for this application. + + Parameters + ----------- + name: :class:`str` + The name of the achievement. + name_localizations: Mapping[:class:`Locale`, :class:`str`] + The localized names of the achievement. + description: :class:`str` + The description of the achievement. + description_localizations: Mapping[:class:`Locale`, :class:`str`] + The localized descriptions of the achievement. + icon: :class:`bytes` + The icon of the achievement. + secure: :class:`bool` + Whether the achievement is secure. + secret: :class:`bool` + Whether the achievement is secret. + + Raises + ------- + Forbidden + You do not have permissions to create achievements. + HTTPException + Creating the achievement failed. + + Returns + -------- + :class:`Achievement` + The created achievement. + """ + state = self._state + data = await state.http.create_achievement( + self.id, + name=name, + name_localizations={str(k): v for k, v in name_localizations.items()} if name_localizations else None, + description=description, + description_localizations={str(k): v for k, v in description_localizations.items()} + if description_localizations + else None, + icon=_bytes_to_base64_data(icon), + secure=secure, + secret=secret, + ) + return Achievement(state=state, data=data) + + async def entitlements( + self, + *, + user: Optional[Snowflake] = None, + guild: Optional[Snowflake] = None, + skus: Optional[List[Snowflake]] = None, + limit: Optional[int] = 100, + before: Optional[SnowflakeTime] = None, + after: Optional[SnowflakeTime] = None, + oldest_first: bool = MISSING, + with_payments: bool = False, + exclude_ended: bool = False, + ) -> AsyncIterator[Entitlement]: + """Returns an :term:`asynchronous iterator` that enables receiving this application's entitlements. + + Examples + --------- + + Usage :: + + counter = 0 + async for entitlement in application.entitlements(limit=200, user=client.user): + if entitlement.consumed: + counter += 1 + + Flattening into a list: :: + + entitlements = [entitlement async for entitlement in application.entitlements(limit=123)] + # entitlements is now a list of Entitlement... + + All parameters are optional. + + Parameters + ----------- + user: Optional[:class:`User`] + The user to retrieve entitlements for. + guild: Optional[:class:`Guild`] + The guild to retrieve entitlements for. + skus: Optional[List[:class:`SKU`]] + The SKUs to retrieve entitlements for. + limit: Optional[:class:`int`] + The number of payments to retrieve. + If ``None``, retrieves every entitlement the application has. Note, however, + that this would make it a slow operation. + before: Optional[Union[:class:`abc.Snowflake`, :class:`datetime.datetime`]] + Retrieve entitlements 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:`abc.Snowflake`, :class:`datetime.datetime`]] + Retrieve entitlements 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. + oldest_first: :class:`bool` + If set to ``True``, return entitlements in oldest->newest order. Defaults to ``True`` if + ``after`` is specified, otherwise ``False``. + with_payments: :class:`bool` + Whether to include partial payment info in the response. + exclude_ended: :class:`bool` + Whether to exclude entitlements that have ended. + + Raises + ------ + HTTPException + The request to get payments failed. + + Yields + ------- + :class:`Entitlement` + The entitlement retrieved. + """ + + _state = self._state + + async def _after_strategy(retrieve, after, limit): + after_id = after.id if after else None + data = await _state.http.get_app_entitlements( + self.id, + limit=retrieve, + after=after_id, + user_id=user.id if user else None, + guild_id=guild.id if guild else None, + sku_ids=[sku.id for sku in skus] if skus else None, + with_payments=with_payments, + exclude_ended=exclude_ended, + ) + + if data: + if limit is not None: + limit -= len(data) + + after = Object(id=int(data[0]['id'])) + + return data, after, limit + + async def _before_strategy(retrieve, before, limit): + before_id = before.id if before else None + data = await _state.http.get_app_entitlements( + self.id, + limit=retrieve, + before=before_id, + user_id=user.id if user else None, + guild_id=guild.id if guild else None, + sku_ids=[sku.id for sku in skus] if skus else None, + with_payments=with_payments, + exclude_ended=exclude_ended, + ) + if data: + if limit is not None: + limit -= len(data) + + before = Object(id=int(data[-1]['id'])) + + return data, before, 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 oldest_first in (MISSING, None): + reverse = after is not None + else: + reverse = oldest_first + + after = after or OLDEST_OBJECT + predicate = None + + if reverse: + strategy, state = _after_strategy, after + if before: + predicate = lambda m: int(m['id']) < before.id + else: + strategy, state = _before_strategy, before + if after and after != OLDEST_OBJECT: + predicate = lambda m: int(m['id']) > after.id + + while True: + retrieve = min(100 if limit is None else 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) < 100: + limit = 0 + + if reverse: + data = reversed(data) + if predicate: + data = filter(predicate, data) + + for entitlement in data: + yield Entitlement(data=entitlement, state=_state) + + async def fetch_entitlement(self, entitlement_id: int, /) -> Entitlement: + """|coro| + + Retrieves an entitlement from this application. + + Parameters + ----------- + entitlement_id: :class:`int` + The ID of the entitlement to fetch. + + Raises + ------ + HTTPException + Fetching the entitlement failed. + + Returns + ------- + :class:`Entitlement` + The entitlement retrieved. + """ + state = self._state + data = await state.http.get_app_entitlement(self.id, entitlement_id) + return Entitlement(data=data, state=state) + + async def gift_batches(self) -> List[GiftBatch]: + """|coro| + + Retrieves the gift batches for this application. + + Raises + ------ + HTTPException + Fetching the gift batches failed. + + Returns + ------- + List[:class:`GiftBatch`] + The gift batches retrieved. + """ + state = self._state + app_id = self.id + data = await state.http.get_gift_batches(app_id) + return [GiftBatch(data=batch, state=state, application_id=app_id) for batch in data] + + async def create_gift_batch( + self, + sku: Snowflake, + *, + amount: int, + description: str, + entitlement_branches: Optional[List[Snowflake]] = None, + entitlement_starts_at: Optional[date] = None, + entitlement_ends_at: Optional[date] = None, + ) -> GiftBatch: + """|coro| + + Creates a gift batch for the specified SKU. + + Parameters + ----------- + sku: :class:`SKU` + The SKU to create the gift batch for. + amount: :class:`int` + The amount of gifts to create in the batch. + description: :class:`str` + The description of the gift batch. + entitlement_branches: List[:class:`ApplicationBranch`] + The branches to grant in the gifts. + entitlement_starts_at: :class:`datetime.date` + When the entitlement is valid from. + entitlement_ends_at: :class:`datetime.date` + When the entitlement is valid until. + + Raises + ------ + Forbidden + You do not have permissions to create a gift batch. + HTTPException + Creating the gift batch failed. + + Returns + ------- + :class:`GiftBatch` + The gift batch created. + """ + state = self._state + app_id = self.id + data = await state.http.create_gift_batch( + app_id, + sku.id, + amount, + description, + entitlement_branches=[branch.id for branch in entitlement_branches] if entitlement_branches else None, + entitlement_starts_at=entitlement_starts_at.isoformat() if entitlement_starts_at else None, + entitlement_ends_at=entitlement_ends_at.isoformat() if entitlement_ends_at else None, + ) + return GiftBatch(data=data, state=state, application_id=app_id) + + async def branches(self) -> List[ApplicationBranch]: + """|coro| - @property - def guild(self) -> Optional[Guild]: - """Optional[:class:`Guild`]: If this application is a game sold on Discord, - this field will be the guild to which it has been linked. + Retrieves the branches for this application. + + Raises + ------ + HTTPException + Fetching the branches failed. + + Returns + ------- + List[:class:`ApplicationBranch`] + The branches retrieved. """ - return self._state._get_guild(self.guild_id) + state = self._state + app_id = self.id + data = await state.http.get_app_branches(app_id) + return [ApplicationBranch(data=branch, state=state, application_id=app_id) for branch in data] - async def edit( - self, - *, - name: str = MISSING, - description: Optional[str] = MISSING, - icon: Optional[bytes] = MISSING, - cover_image: Optional[bytes] = MISSING, - tags: Collection[str] = MISSING, - terms_of_service_url: Optional[str] = MISSING, - privacy_policy_url: Optional[str] = MISSING, - interactions_endpoint_url: Optional[str] = MISSING, - redirect_uris: Collection[str] = MISSING, - rpc_origins: Collection[str] = MISSING, - public: bool = MISSING, - require_code_grant: bool = MISSING, - flags: ApplicationFlags = MISSING, - team: Snowflake = MISSING, - ) -> None: + async def create_branch(self, name: str) -> ApplicationBranch: """|coro| - Edits the application. + Creates a branch for this application. + + .. note:: + + The first branch created will always be called ``master`` + and share the same ID as the application. Parameters ----------- name: :class:`str` - The name of the application. - description: :class:`str` - The description of the application. - icon: Optional[:class:`bytes`] - The icon of the application. - cover_image: Optional[:class:`bytes`] - The cover image of the application. - tags: List[:class:`str`] - A list of tags that describe the application. - terms_of_service_url: Optional[:class:`str`] - The URL to the terms of service of the application. - privacy_policy_url: Optional[:class:`str`] - The URL to the privacy policy of the application. - interactions_endpoint_url: Optional[:class:`str`] - The URL interactions will be sent to, if set. - redirect_uris: List[:class:`str`] - A list of redirect URIs authorized for this application. - rpc_origins: List[:class:`str`] - A list of RPC origins authorized for this application. - public: :class:`bool` - Whether the application is public or not. - require_code_grant: :class:`bool` - Whether the application requires a code grant or not. - flags: :class:`ApplicationFlags` - The flags of the application. - team: :class:`~abc.Snowflake` - The team to transfer the application to. + The name of the branch. Raises - ------- - Forbidden - You do not have permissions to edit this application. + ------ HTTPException - Editing the application failed. + Creating the branch failed. + + Returns + ------- + :class:`ApplicationBranch` + The branch created. """ - payload = {} - if name is not MISSING: - payload['name'] = name or '' - if description is not MISSING: - payload['description'] = description or '' - if icon is not MISSING: - if icon is not None: - payload['icon'] = utils._bytes_to_base64_data(icon) - else: - payload['icon'] = '' - if cover_image is not MISSING: - if cover_image is not None: - payload['cover_image'] = utils._bytes_to_base64_data(cover_image) - else: - payload['cover_image'] = '' - if tags is not MISSING: - payload['tags'] = tags - if terms_of_service_url is not MISSING: - payload['terms_of_service_url'] = terms_of_service_url or '' - if privacy_policy_url is not MISSING: - payload['privacy_policy_url'] = privacy_policy_url or '' - if interactions_endpoint_url is not MISSING: - payload['interactions_endpoint_url'] = interactions_endpoint_url or '' - if redirect_uris is not MISSING: - payload['redirect_uris'] = redirect_uris - if rpc_origins is not MISSING: - payload['rpc_origins'] = rpc_origins - if public is not MISSING: - payload['integration_public'] = public - if require_code_grant is not MISSING: - payload['integration_require_code_grant'] = require_code_grant - if flags is not MISSING: - payload['flags'] = flags.value + state = self._state + app_id = self.id + data = await state.http.create_app_branch(app_id, name) + return ApplicationBranch(data=data, state=state, application_id=app_id) - if team is not MISSING: - await self._state.http.transfer_application(self.id, team.id) + async def manifest_labels(self) -> List[ManifestLabel]: + """|coro| - data = await self._state.http.edit_application(self.id, payload) + Retrieves the manifest labels for this application. - self._update(data) + Raises + ------ + HTTPException + Fetching the manifest labels failed. + + Returns + ------- + List[:class:`ManifestLabel`] + The manifest labels retrieved. + """ + state = self._state + app_id = self.id + data = await state.http.get_app_manifest_labels(app_id) + return [ManifestLabel(data=label, application_id=app_id) for label in data] - async def reset_secret(self) -> str: + async def fetch_discoverability(self) -> Tuple[ApplicationDiscoverabilityState, ApplicationDiscoveryFlags]: """|coro| - Resets the application's secret. + Retrieves the discoverability state for this application. + + .. note:: + + This method is an API call. For general usage, consider + :attr:`discoverability_state` and :attr:`discovery_eligibility_flags` instead. Raises ------ - Forbidden - You do not have permissions to reset the secret. HTTPException - Resetting the secret failed. + Fetching the discoverability failed. Returns ------- - :class:`str` - The new secret. + Tuple[:class:`ApplicationDiscoverabilityState`, :class:`ApplicationDiscoveryFlags`] + The discoverability retrieved. """ - data = await self._state.http.reset_secret(self.id) - return data['secret'] # type: ignore # Usually not there + data = await self._state.http.get_app_discoverability(self.id) + return try_enum( + ApplicationDiscoverabilityState, data['discoverability_state'] + ), ApplicationDiscoveryFlags._from_value(data['discovery_eligibility_flags']) - async def fetch_bot(self) -> ApplicationBot: + async def fetch_embedded_activity_config(self) -> EmbeddedActivityConfig: """|coro| - Fetches the bot attached to this application. + Retrieves the embedded activity configuration for this application. + + .. note:: + + This method is an API call. For general usage, consider + :attr:`PartialApplication.embedded_activity_config` instead. Raises ------ Forbidden - You do not have permissions to fetch the bot, - or the application does not have a bot. + You do not have permissions to fetch the embedded activity config. HTTPException - Fetching the bot failed. + Fetching the embedded activity config failed. Returns ------- - :class:`ApplicationBot` - The bot attached to this application. + :class:`EmbeddedActivityConfig` + The embedded activity config retrieved. """ - data = await self._state.http.edit_bot(self.id, {}) - data['public'] = self.public # type: ignore - data['require_code_grant'] = self.require_code_grant # type: ignore + data = await self._state.http.get_embedded_activity_config(self.id) + return EmbeddedActivityConfig(data=data, application=self) + + async def edit_embedded_activity_config( + self, + *, + supported_platforms: List[EmbeddedActivityPlatform] = MISSING, + orientation_lock_state: EmbeddedActivityOrientation = MISSING, + preview_video_asset: Optional[Snowflake] = MISSING, + ) -> EmbeddedActivityConfig: + """|coro| + + Edits the application's embedded activity configuration. - self.bot = bot = ApplicationBot(data=data, state=self._state, application=self) - return bot + Parameters + ----------- + supported_platforms: List[:class:`EmbeddedActivityPlatform`] + A list of platforms that the application supports. + orientation_lock_state: :class:`EmbeddedActivityOrientation` + The mobile orientation lock state of the application. + preview_video_asset: Optional[:class:`ApplicationAsset`] + The preview video asset of the embedded activity. + + Raises + ------- + Forbidden + You are not allowed to edit this application's configuration. + HTTPException + Editing the configuration failed. + + Returns + -------- + :class:`EmbeddedActivityConfig` + The edited configuration. + """ + data = await self._state.http.edit_embedded_activity_config( + self.id, + supported_platforms=[str(x) for x in (supported_platforms or [])], + orientation_lock_state=int(orientation_lock_state), + preview_video_asset_id=(preview_video_asset.id if preview_video_asset else None) + if preview_video_asset is not MISSING + else None, + ) + if self.embedded_activity_config is not None: + self.embedded_activity_config._update(data) + else: + self.embedded_activity_config = EmbeddedActivityConfig(data=data, application=self) + return self.embedded_activity_config - async def create_bot(self) -> None: + async def secret(self) -> str: """|coro| - Creates a bot attached to this application. + Gets the application's secret. - This does not fetch or cache the bot. + This revokes all previous secrets. Raises ------ Forbidden - You do not have permissions to create bots. + You do not have permissions to reset the secret. HTTPException - Creating the bot failed. - """ - await self._state.http.botify_app(self.id) + Getting the secret failed. + Returns + ------- + :class:`str` + The new secret. + """ + data = await self._state.http.reset_secret(self.id) + return data['secret'] -class InteractionApplication(Hashable): - """Represents a very partial Application received in interaction contexts. - .. versionadded:: 2.0 +class IntegrationApplication(Hashable): + """Represents a very partial application received in integration/interaction contexts. .. container:: operations @@ -772,6 +3287,8 @@ class InteractionApplication(Hashable): Returns the application's name. + .. versionadded:: 2.0 + Attributes ------------- id: :class:`int` @@ -786,28 +3303,33 @@ class InteractionApplication(Hashable): The type of application. primary_sku_id: Optional[:class:`int`] The application's primary SKU ID, if any. + This can be an application's game SKU, subscription SKU, etc. + role_connections_verification_url: Optional[:class:`str`] + The application's connection verification URL which will render the application as + a verification method in the guild's role verification configuration. """ __slots__ = ( '_state', 'id', 'name', + 'bot', 'description', + 'type', + 'primary_sku_id', + 'role_connections_verification_url', '_icon', '_cover_image', - 'primary_sku_id', - 'type', - 'bot', ) - def __init__(self, *, state: ConnectionState, data: dict): + def __init__(self, *, state: ConnectionState, data: IntegrationApplicationPayload): self._state: ConnectionState = state self._update(data) def __str__(self) -> str: return self.name - def _update(self, data: dict) -> None: + def _update(self, data: IntegrationApplicationPayload) -> None: self.id: int = int(data['id']) self.name: str = data['name'] self.description: str = data.get('description') or '' @@ -815,25 +3337,185 @@ class InteractionApplication(Hashable): self._icon: Optional[str] = data.get('icon') self._cover_image: Optional[str] = data.get('cover_image') - self.bot: Optional[User] = self._state.create_user(data['bot']) if data.get('bot') else None + self.bot: Optional[User] = self._state.create_user(data['bot']) if 'bot' in data else None self.primary_sku_id: Optional[int] = utils._get_as_snowflake(data, 'primary_sku_id') + self.role_connections_verification_url: Optional[str] = data.get('role_connections_verification_url') def __repr__(self) -> str: - return f'<{self.__class__.__name__} id={self.id} name={self.name!r}>' + return f'' @property def icon(self) -> Optional[Asset]: - """Optional[:class:`.Asset`]: Retrieves the application's icon asset, if any.""" + """Optional[:class:`Asset`]: Retrieves the application's icon asset, if any.""" if self._icon is None: return None return Asset._from_icon(self._state, self.id, self._icon, path='app') @property def cover_image(self) -> Optional[Asset]: - """Optional[:class:`.Asset`]: Retrieves the cover image on a store embed, if any. - - This is only available if the application is a game sold on Discord. - """ + """Optional[:class:`Asset`]: Retrieves the application's cover image, if any.""" if self._cover_image is None: return None return Asset._from_cover_image(self._state, self.id, self._cover_image) + + @property + def primary_sku_url(self) -> Optional[str]: + """:class:`str`: The URL to the primary SKU of the application, if any.""" + if self.primary_sku_id: + return f'https://discord.com/store/skus/{self.primary_sku_id}/unknown' + + async def assets(self) -> List[ApplicationAsset]: + """|coro| + + Retrieves the assets of this application. + + Raises + ------ + HTTPException + Retrieving the assets failed. + + Returns + ------- + List[:class:`ApplicationAsset`] + The application's assets. + """ + data = await self._state.http.get_app_assets(self.id) + return [ApplicationAsset(data=d, application=self) for d in data] + + async def published_store_listings(self, *, localize: bool = True) -> List[StoreListing]: + """|coro| + + Retrieves all published store listings for this application. + + Parameters + ----------- + localize: :class:`bool` + Whether to localize the store listings to the current user's locale. + If ``False`` then all localizations are returned. + + Raises + ------- + HTTPException + Retrieving the listings failed. + + Returns + ------- + List[:class:`StoreListing`] + The store listings. + """ + state = self._state + data = await state.http.get_app_store_listings(self.id, country_code=state.country_code or 'US', localize=localize) + return [StoreListing(state=state, data=d) for d in data] + + async def primary_store_listing(self, *, localize: bool = True) -> StoreListing: + """|coro| + + Retrieves the primary store listing of this application. + + This is the public store listing of the primary SKU. + + Parameters + ----------- + localize: :class:`bool` + Whether to localize the store listings to the current user's locale. + If ``False`` then all localizations are returned. + + Raises + ------ + NotFound + The application does not have a primary SKU. + HTTPException + Retrieving the store listing failed. + + Returns + ------- + :class:`StoreListing` + The application's primary store listing, if any. + """ + state = self._state + data = await state.http.get_app_store_listing(self.id, country_code=state.country_code or 'US', localize=localize) + return StoreListing(state=state, data=data) + + async def entitlements(self, *, exclude_consumed: bool = True) -> List[Entitlement]: + """|coro| + + Retrieves the entitlements this account has granted for this application. + + Parameters + ----------- + exclude_consumed: :class:`bool` + Whether to exclude consumed entitlements. + + Raises + ------- + Forbidden + You do not have permissions to fetch entitlements. + HTTPException + Fetching the entitlements failed. + + Returns + -------- + List[:class:`Entitlement`] + The entitlements retrieved. + """ + state = self._state + data = await state.http.get_user_app_entitlements(self.id, exclude_consumed=exclude_consumed) + return [Entitlement(data=entitlement, state=state) for entitlement in data] + + async def ticket(self) -> str: + """|coro| + + Retrieves the license ticket for this application. + + Raises + ------- + HTTPException + Retrieving the ticket failed. + + Returns + -------- + :class:`str` + The ticket retrieved. + """ + state = self._state + data = await state.http.get_app_ticket(self.id) + return data['ticket'] + + async def entitlement_ticket(self) -> str: + """|coro| + + Retrieves the entitlement ticket for this application. + + Raises + ------- + HTTPException + Retrieving the ticket failed. + + Returns + -------- + :class:`str` + The ticket retrieved. + """ + state = self._state + data = await state.http.get_app_entitlement_ticket(self.id) + return data['ticket'] + + async def activity_statistics(self) -> List[ApplicationActivityStatistics]: + """|coro| + + Retrieves the activity usage statistics for this application. + + Raises + ------- + HTTPException + Retrieving the statistics failed. + + Returns + -------- + List[:class:`ApplicationActivityStatistics`] + The statistics retrieved. + """ + state = self._state + app_id = self.id + data = await state.http.get_app_activity_statistics(app_id) + return [ApplicationActivityStatistics(data=activity, state=state, application_id=app_id) for activity in data] diff --git a/discord/asset.py b/discord/asset.py index 6af5777c7..90fde6d2a 100644 --- a/discord/asset.py +++ b/discord/asset.py @@ -344,6 +344,26 @@ class Asset(AssetMixin): animated=animated, ) + @classmethod + def _from_astore_asset(cls, state, app_id: int, hash: str) -> Asset: + animated = hash.startswith('a_') + format = 'gif' if animated else 'png' + return cls( + state, + url=f'{cls.BASE}/app-assets/{app_id}/{hash}.{format}', + key=hash, + animated=animated, + ) + + @classmethod + def _from_achievement_icon(cls, state, app_id: int, achievement_id: int, icon_hash: str) -> Asset: + return cls( + state, + url=f'{cls.BASE}/app-assets/{app_id}/achievements/{achievement_id}/icons/{icon_hash}.png', + key=icon_hash, + animated=False, + ) + def __str__(self) -> str: return self._url diff --git a/discord/billing.py b/discord/billing.py new file mode 100644 index 000000000..d40387253 --- /dev/null +++ b/discord/billing.py @@ -0,0 +1,381 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Dolfies + +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 datetime import datetime +from typing import TYPE_CHECKING, Optional, Union + +from .enums import ( + PaymentGateway, + PaymentSourceType, + try_enum, +) +from .flags import PaymentSourceFlags +from .mixins import Hashable +from .utils import MISSING + +if TYPE_CHECKING: + from datetime import date + from typing_extensions import Self + + from .state import ConnectionState + from .types.billing import ( + BillingAddress as BillingAddressPayload, + PartialPaymentSource as PartialPaymentSourcePayload, + PaymentSource as PaymentSourcePayload, + PremiumUsage as PremiumUsagePayload, + ) + +__all__ = ( + 'BillingAddress', + 'PaymentSource', + 'PremiumUsage', +) + + +class BillingAddress: + """Represents a billing address. + + .. container:: operations + + .. describe:: x == y + + Checks if two billing addresses are equal. + + .. describe:: x != y + + Checks if two billing addresses are not equal. + + .. describe:: hash(x) + + Returns the address' hash. + + .. versionadded:: 2.0 + + Attributes + ---------- + name: :class:`str` + The payment source's name. + address: :class:`str` + The location's address. + postal_code: Optional[:class:`str`] + The location's postal code. + city: :class:`str` + The location's city. + state: Optional[:class:`str`] + The location's state or province. + country: :class:`str` + The location's country. + email: Optional[:class:`str`] + The email address associated with the payment source, if any. + """ + + __slots__ = ('_state', 'name', 'address', 'postal_code', 'city', 'state', 'country', 'email') + + def __init__( + self, + *, + name: str, + address: str, + city: str, + country: str, + state: Optional[str] = None, + postal_code: Optional[str] = None, + email: Optional[str] = None, + _state: Optional[ConnectionState] = None, + ) -> None: + self._state = _state + + self.name = name + self.address = address + self.postal_code = postal_code + self.city = city + self.state = state + self.country = country + self.email = email + + def __repr__(self) -> str: + return f'' + + def __eq__(self, other: object) -> bool: + return isinstance(other, BillingAddress) and self.to_dict() == other.to_dict() + + def __ne__(self, other: object) -> bool: + if not isinstance(other, BillingAddress): + return True + return self.to_dict() != other.to_dict() + + def __hash__(self) -> int: + return hash(self.to_dict()) + + @classmethod + def from_dict(cls, data: BillingAddressPayload, state: ConnectionState) -> Self: + address = '\n'.join(filter(None, (data['line_1'], data.get('line_2')))) + return cls( + _state=state, + name=data['name'], + address=address, + postal_code=data.get('postal_code'), + city=data['city'], + state=data.get('state'), + country=data['country'], + email=data.get('email'), + ) + + def to_dict(self) -> dict: + line1, _, line2 = self.address.partition('\n') + data = { + 'name': self.name, + 'line_1': line1, + 'line_2': line2 or '', + 'city': self.city, + 'country': self.country, + } + if self.postal_code: + data['postal_code'] = self.postal_code + if self.state: + data['state'] = self.state + if self.email: + data['email'] = self.email + return data + + async def validate(self) -> str: + """|coro| + + Validates the billing address. + + Raises + ------ + TypeError + The billing address does not have state attached. + HTTPException + The billing address is invalid. + + Returns + ------- + :class:`str` + The billing address token. + """ + if self._state is None: + raise TypeError('BillingAddress does not have state available') + + data = await self._state.http.validate_billing_address(self.to_dict()) + return data['token'] + + +class PaymentSource(Hashable): + """Represents a payment source. + + .. container:: operations + + .. describe:: x == y + + Checks if two payment sources are equal. + + .. describe:: x != y + + Checks if two payment sources are not equal. + + .. describe:: hash(x) + + Returns the source's hash. + + .. versionadded:: 2.0 + + Attributes + ---------- + id: :class:`int` + The ID of the payment source. + brand: Optional[:class:`str`] + The brand of the payment source. This is only available for cards. + country: Optional[:class:`str`] + The country of the payment source. Not available in all contexts. + partial_card_number: Optional[:class:`str`] + The last four digits of the payment source. This is only available for cards. + billing_address: Optional[:class:`BillingAddress`] + The billing address of the payment source. Not available in all contexts. + type: :class:`PaymentSourceType` + The type of the payment source. + payment_gateway: :class:`PaymentGateway` + The payment gateway of the payment source. + default: :class:`bool` + Whether the payment source is the default payment source. + invalid: :class:`bool` + Whether the payment source is invalid. + expires_at: Optional[:class:`datetime.date`] + When the payment source expires. This is only available for cards. + email: Optional[:class:`str`] + The email address associated with the payment source, if any. + This is only available for PayPal. + bank: Optional[:class:`str`] + The bank associated with the payment source, if any. + This is only available for certain payment sources. + username: Optional[:class:`str`] + The username associated with the payment source, if any. + This is only available for Venmo. + """ + + __slots__ = ( + '_state', + 'id', + 'brand', + 'country', + 'partial_card_number', + 'billing_address', + 'type', + 'payment_gateway', + 'default', + 'invalid', + 'expires_at', + 'email', + 'bank', + 'username', + '_flags', + ) + + def __init__(self, *, data: Union[PaymentSourcePayload, PartialPaymentSourcePayload], state: ConnectionState) -> None: + self._state = state + self._update(data) + + def __repr__(self) -> str: + return f'' + + def _update(self, data: Union[PaymentSourcePayload, PartialPaymentSourcePayload]) -> None: + self.id: int = int(data['id']) + self.brand: Optional[str] = data.get('brand') + self.country: Optional[str] = data.get('country') + self.partial_card_number: Optional[str] = data.get('last_4') + self.billing_address: Optional[BillingAddress] = ( + BillingAddress.from_dict(data['billing_address'], state=self._state) if 'billing_address' in data else None # type: ignore # ??? + ) + + self.type: PaymentSourceType = try_enum(PaymentSourceType, data['type']) + self.payment_gateway: PaymentGateway = try_enum(PaymentGateway, data['payment_gateway']) + self.default: bool = data.get('default', False) + self.invalid: bool = data['invalid'] + self._flags: int = data.get('flags', 0) + + month = data.get('expires_month') + year = data.get('expires_year') + self.expires_at: Optional[date] = datetime(year=year, month=month or 1, day=1).date() if year else None + + self.email: Optional[str] = data.get('email') + self.bank: Optional[str] = data.get('bank') + self.username: Optional[str] = data.get('username') + + if not self.country and self.billing_address: + self.country = self.billing_address.country + + if not self.email and self.billing_address: + self.email = self.billing_address.email + + @property + def flags(self) -> PaymentSourceFlags: + """:class:`PaymentSourceFlags`: Returns the payment source's flags.""" + return PaymentSourceFlags._from_value(self._flags) + + async def edit( + self, *, billing_address: BillingAddress = MISSING, default: bool = MISSING, expires_at: date = MISSING + ) -> None: + """|coro| + + Edits the payment source. + + Parameters + ---------- + billing_address: :class:`BillingAddress` + The billing address of the payment source. + default: :class:`bool` + Whether the payment source is the default payment source. + expires_at: :class:`datetime.date` + When the payment source expires. This is only applicable to cards. + + Raises + ------ + HTTPException + Editing the payment source failed. + """ + payload = {} + if billing_address is not MISSING: + payload['billing_address'] = billing_address.to_dict() + if default is not MISSING: + payload['default'] = default + if expires_at is not MISSING: + payload['expires_month'] = expires_at.month + payload['expires_year'] = expires_at.year + + data = await self._state.http.edit_payment_source(self.id, payload) + self._update(data) + + async def delete(self) -> None: + """|coro| + + Deletes the payment source. + + Raises + ------ + HTTPException + Deleting the payment source failed. + """ + await self._state.http.delete_payment_source(self.id) + + +class PremiumUsage: + """Represents the usage of a user's premium perks. + + .. versionadded:: 2.0 + + Attributes + ---------- + sticker_sends: :class:`int` + The number of premium sticker sends. + animated_emojis: :class:`int` + The number of animated emojis used. + global_emojis: :class:`int` + The number of global emojis used. + large_uploads: :class:`int` + The number of large uploads made. + hd_streams: :class:`int` + The number of HD streams. + hd_hours_streamed: :class:`int` + The number of hours streamed in HD. + """ + + __slots__ = ( + 'sticker_sends', + 'animated_emojis', + 'global_emojis', + 'large_uploads', + 'hd_streams', + 'hd_hours_streamed', + ) + + def __init__(self, *, data: PremiumUsagePayload) -> None: + self.sticker_sends: int = data['nitro_sticker_sends']['value'] + self.animated_emojis: int = data['total_animated_emojis']['value'] + self.global_emojis: int = data['total_global_emojis']['value'] + self.large_uploads: int = data['total_large_uploads']['value'] + self.hd_streams: int = data['total_hd_streams']['value'] + self.hd_hours_streamed: int = data['hd_hours_streamed']['value'] diff --git a/discord/calls.py b/discord/calls.py index 47bac76c5..46bf36f3a 100644 --- a/discord/calls.py +++ b/discord/calls.py @@ -109,7 +109,7 @@ class CallMessage: The timedelta object representing the duration. """ if self.ended_timestamp is None: - return datetime.datetime.utcnow() - self.message.created_at + return utils.utcnow() - self.message.created_at else: return self.ended_timestamp - self.message.created_at diff --git a/discord/channel.py b/discord/channel.py index 915b33390..46b957dab 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -2286,7 +2286,7 @@ class DMChannel(discord.abc.Messageable, discord.abc.Connectable, Hashable): @property def notification_settings(self) -> ChannelSettings: - """:class:`~discord.ChannelSettings`: Returns the notification settings for this channel. + """:class:`ChannelSettings`: Returns the notification settings for this channel. If not found, an instance is created with defaults applied. This follows Discord behaviour. @@ -2556,12 +2556,40 @@ class GroupChannel(discord.abc.Messageable, discord.abc.Connectable, Hashable): owner_id: :class:`int` The owner ID that owns the group channel. + .. versionadded:: 2.0 + managed: :class:`bool` + Whether the group channel is managed by an application. + + This restricts the operations that can be performed on the channel, + and means :attr:`owner` will usually be ``None``. + + .. versionadded:: 2.0 + application_id: Optional[:class:`int`] + The ID of the managing application, if any. + .. versionadded:: 2.0 name: Optional[:class:`str`] The group channel's name if provided. + nicks: Dict[:class:`User`, :class:`str`] + A mapping of users to their respective nicknames in the group channel. + + .. versionadded:: 2.0 """ - __slots__ = ('last_message_id', 'id', 'recipients', 'owner_id', '_icon', 'name', 'me', '_state', '_accessed') + __slots__ = ( + 'last_message_id', + 'id', + 'recipients', + 'owner_id', + 'managed', + 'application_id', + 'nicks', + '_icon', + 'name', + 'me', + '_state', + '_accessed', + ) def __init__(self, *, me: ClientUser, state: ConnectionState, data: GroupChannelPayload): self._state: ConnectionState = state @@ -2571,11 +2599,14 @@ class GroupChannel(discord.abc.Messageable, discord.abc.Connectable, Hashable): self._accessed: bool = False def _update(self, data: GroupChannelPayload) -> None: - self.owner_id: Optional[int] = utils._get_as_snowflake(data, 'owner_id') + self.owner_id: int = int(data['owner_id']) self._icon: Optional[str] = data.get('icon') self.name: Optional[str] = data.get('name') self.recipients: List[User] = [self._state.store_user(u) for u in data.get('recipients', [])] self.last_message_id: Optional[int] = utils._get_as_snowflake(data, 'last_message_id') + self.managed: bool = data.get('managed', False) + self.application_id: Optional[int] = utils._get_as_snowflake(data, 'application_id') + self.nicks: Dict[User, str] = {utils.get(self.recipients, id=int(k)): v for k, v in data.get('nicks', {}).items()} # type: ignore def _get_voice_client_key(self) -> Tuple[int, str]: return self.me.id, 'self_id' @@ -2599,17 +2630,19 @@ class GroupChannel(discord.abc.Messageable, discord.abc.Connectable, Hashable): if self.name: return self.name - if len(self.recipients) == 0: + recipients = [x for x in self.recipients if x.id != self.me.id] + + if len(recipients) == 0: return 'Unnamed' - return ', '.join(map(lambda x: x.name, self.recipients)) + return ', '.join(map(lambda x: x.name, recipients)) def __repr__(self) -> str: return f'' @property def notification_settings(self) -> ChannelSettings: - """:class:`~discord.ChannelSettings`: Returns the notification settings for this channel. + """:class:`ChannelSettings`: Returns the notification settings for this channel. If not found, an instance is created with defaults applied. This follows Discord behaviour. @@ -2621,9 +2654,10 @@ class GroupChannel(discord.abc.Messageable, discord.abc.Connectable, Hashable): ) @property - def owner(self) -> User: - """:class:`User`: The owner that owns the group channel.""" - return utils.find(lambda u: u.id == self.owner_id, self.recipients) # type: ignore # All recipients are always present + def owner(self) -> Optional[User]: + """Optional[:class:`User`]: The owner that owns the group channel.""" + # Only reason it wouldn't be in recipients is if it's a managed channel + return utils.get(self.recipients, id=self.owner_id) or self._state.get_user(self.owner_id) @property def call(self) -> Optional[PrivateCall]: @@ -2683,7 +2717,7 @@ class GroupChannel(discord.abc.Messageable, discord.abc.Connectable, Hashable): Actual direct messages do not really have the concept of permissions. - This returns all the Text related permissions set to ``True`` except: + If a recipient, this returns all the Text related permissions set to ``True`` except: - :attr:`~Permissions.send_tts_messages`: You cannot send TTS messages in a DM. - :attr:`~Permissions.manage_messages`: You cannot delete others messages in a DM. @@ -2704,18 +2738,24 @@ class GroupChannel(discord.abc.Messageable, discord.abc.Connectable, Hashable): :class:`Permissions` The resolved permissions for the user. """ - base = Permissions.text() - base.read_messages = True - base.send_tts_messages = False - base.manage_messages = False - base.mention_everyone = True + if obj.id in [x.id for x in self.recipients]: + base = Permissions.text() + base.read_messages = True + base.send_tts_messages = False + base.manage_messages = False + base.mention_everyone = True + if not self.managed: + base.create_instant_invite = True + else: + base = Permissions.none() if obj.id == self.owner_id: + # Applications can kick members even without being a recipient base.kick_members = True return base - async def add_recipients(self, *recipients: Snowflake) -> None: + async def add_recipients(self, *recipients: Snowflake, nicks: Optional[Dict[Snowflake, str]] = None) -> None: r"""|coro| Adds recipients to this group. @@ -2729,17 +2769,25 @@ class GroupChannel(discord.abc.Messageable, discord.abc.Connectable, Hashable): ----------- \*recipients: :class:`~discord.abc.Snowflake` An argument list of users to add to this group. + If the user is of type :class:`Object`, then the ``nick`` attribute + is used as the nickname for the added recipient. + nicks: Optional[Dict[:class:`~discord.abc.Snowflake`, :class:`str`]] + A mapping of user IDs to nicknames to use for the added recipients. + + .. versionadded:: 2.0 Raises ------- + Forbidden + You do not have permissions to add a recipient to this group. HTTPException Adding a recipient to this group failed. """ - # TODO: wait for the corresponding WS event + nicknames = {k.id: v for k, v in nicks.items()} if nicks else {} await self._get_channel() req = self._state.http.add_group_recipient for recipient in recipients: - await req(self.id, recipient.id) + await req(self.id, recipient.id, getattr(recipient, 'nick', (nicknames.get(recipient.id) if nicks else None))) async def remove_recipients(self, *recipients: Snowflake) -> None: r"""|coro| @@ -2753,10 +2801,11 @@ class GroupChannel(discord.abc.Messageable, discord.abc.Connectable, Hashable): Raises ------- + Forbidden + You do not have permissions to remove a recipient from this group. HTTPException Removing a recipient from this group failed. """ - # TODO: wait for the corresponding WS event await self._get_channel() req = self._state.http.remove_group_recipient for recipient in recipients: @@ -2787,7 +2836,7 @@ class GroupChannel(discord.abc.Messageable, discord.abc.Connectable, Hashable): owner: :class:`~discord.abc.Snowflake` The new owner of the group. - .. versionadded:: 2.0 + .. versionadded:: 2.0 Raises ------- @@ -2804,7 +2853,7 @@ class GroupChannel(discord.abc.Messageable, discord.abc.Connectable, Hashable): payload['icon'] = None else: payload['icon'] = utils._bytes_to_base64_data(icon) - if owner is not MISSING: + if owner: payload['owner'] = owner.id data = await self._state.http.edit_channel(self.id, **payload) @@ -2853,11 +2902,36 @@ class GroupChannel(discord.abc.Messageable, discord.abc.Connectable, Hashable): """ await self.leave(silent=silent) + async def invites(self) -> List[Invite]: + """|coro| + + Returns a list of all active instant invites from this channel. + + .. versionadded:: 2.0 + + Raises + ------- + Forbidden + You do not have proper permissions to get the information. + HTTPException + An error occurred while fetching the information. + + Returns + ------- + List[:class:`Invite`] + The list of invites that are currently active. + """ + state = self._state + data = await state.http.invites_from_channel(self.id) + return [Invite(state=state, data=invite, channel=self) for invite in data] + async def create_invite(self, *, max_age: int = 86400) -> Invite: """|coro| Creates an instant invite from a group channel. + .. versionadded:: 2.0 + Parameters ------------ max_age: :class:`int` @@ -2866,12 +2940,12 @@ class GroupChannel(discord.abc.Messageable, discord.abc.Connectable, Hashable): Raises ------- - ~discord.HTTPException + HTTPException Invite creation failed. Returns -------- - :class:`~discord.Invite` + :class:`Invite` The invite that was created. """ data = await self._state.http.create_group_invite(self.id, max_age=max_age) diff --git a/discord/client.py b/discord/client.py index e350fee9d..f1c98406e 100644 --- a/discord/client.py +++ b/discord/client.py @@ -25,11 +25,13 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations import asyncio +from datetime import datetime import logging import sys import traceback from typing import ( Any, + AsyncIterator, Callable, Coroutine, Dict, @@ -47,14 +49,14 @@ from typing import ( import aiohttp -from .user import BaseUser, User, ClientUser, Note +from .user import _UserTag, User, ClientUser, Note from .invite import Invite from .template import Template from .widget import Widget from .guild import Guild from .emoji import Emoji from .channel import _private_channel_factory, _threaded_channel_factory, GroupChannel, PartialMessageable -from .enums import ActivityType, ChannelType, ConnectionLinkType, ConnectionType, Status, try_enum +from .enums import ActivityType, ChannelType, ConnectionLinkType, ConnectionType, EntitlementType, Status, try_enum from .mentions import AllowedMentions from .errors import * from .enums import Status @@ -66,10 +68,10 @@ from .http import HTTPClient from .state import ConnectionState from . import utils from .utils import MISSING -from .object import Object +from .object import Object, OLDEST_OBJECT from .backoff import ExponentialBackoff from .webhook import Webhook -from .appinfo import Application, PartialApplication +from .appinfo import Application, ApplicationActivityStatistics, Company, EULA, PartialApplication from .stage_instance import StageInstance from .threads import Thread from .sticker import GuildSticker, StandardSticker, StickerPack, _sticker_factory @@ -78,17 +80,28 @@ from .connections import Connection from .team import Team from .member import _ClientStatus from .handlers import CaptchaHandler +from .billing import PaymentSource, PremiumUsage +from .subscriptions import Subscription, SubscriptionItem, SubscriptionInvoice +from .payments import Payment +from .promotions import PricingPromotion, Promotion, TrialOffer +from .entitlements import Entitlement, Gift +from .store import SKU, StoreListing, SubscriptionPlan +from .guild_premium import * +from .library import LibraryApplication if TYPE_CHECKING: from typing_extensions import Self from types import TracebackType from .guild import GuildChannel - from .abc import PrivateChannel, GuildChannel, Snowflake + from .abc import PrivateChannel, GuildChannel, Snowflake, SnowflakeTime from .channel import DMChannel from .message import Message from .member import Member from .voice_client import VoiceProtocol from .settings import GuildSettings + from .billing import BillingAddress + from .enums import PaymentGateway + from .metadata import MetadataObject from .types.snowflake import Snowflake as _Snowflake # fmt: off @@ -381,6 +394,14 @@ class Client: """ return self._connection.preferred_regions + @property + def pending_payments(self) -> List[Payment]: + """List[:class:`.Payment`]: The pending payments that the connected client has. + + .. versionadded:: 2.0 + """ + return list(self._connection.pending_payments.values()) + def is_ready(self) -> bool: """:class:`bool`: Specifies if the client's internal cache is ready for use.""" return self._ready is not MISSING and self._ready.is_set() @@ -1123,7 +1144,7 @@ class Client: .. note:: To retrieve standard stickers, use :meth:`.fetch_sticker`. - or :meth:`.fetch_sticker_packs`. + or :meth:`.sticker_packs`. Returns -------- @@ -1380,8 +1401,7 @@ class Client: Whether to update the settings with the new status and/or custom activity. This will broadcast the change and cause all connected (official) clients to change presence as well. - Defaults to ``True``. Required for setting/editing expires_at - for custom activities. + Required for setting/editing expires_at for custom activities. It's not recommended to change this. Raises @@ -1472,7 +1492,9 @@ class Client: # Guild stuff async def fetch_guilds(self, *, with_counts: bool = True) -> List[Guild]: - """Retrieves all your your guilds. + """|coro| + + Retrieves all your your guilds. .. note:: @@ -1487,7 +1509,6 @@ class Client: ----------- with_counts: :class:`bool` Whether to fill :attr:`.Guild.approximate_member_count` and :attr:`.Guild.approximate_presence_count`. - Defaults to ``True``. Raises ------ @@ -1560,7 +1581,6 @@ class Client: with_counts: :class:`bool` Whether to include count information in the guild. This fills the :attr:`.Guild.approximate_member_count` and :attr:`.Guild.approximate_presence_count`. - Defaults to ``True``. .. versionadded:: 2.0 @@ -2071,7 +2091,7 @@ class Client: NotFound A user with this ID does not exist. Forbidden - Not allowed to fetch this profile. + You do not have a mutual with this user, and and the user is not a bot. HTTPException Fetching the profile failed. @@ -2193,7 +2213,7 @@ class Client: cls, _ = _sticker_factory(data['type']) return cls(state=self._connection, data=data) # type: ignore - async def fetch_sticker_packs(self) -> List[StickerPack]: + async def sticker_packs(self) -> List[StickerPack]: """|coro| Retrieves all available default sticker packs. @@ -2210,8 +2230,9 @@ class Client: List[:class:`.StickerPack`] All available sticker packs. """ - data = await self.http.list_premium_sticker_packs() - return [StickerPack(state=self._connection, data=pack) for pack in data['sticker_packs']] + state = self._connection + data = await self.http.list_premium_sticker_packs(state.country_code or 'US', state.locale) + return [StickerPack(state=state, data=pack) for pack in data['sticker_packs']] async def fetch_sticker_pack(self, pack_id: int, /): """|coro| @@ -2308,7 +2329,11 @@ class Client: return [Connection(data=d, state=state) for d in data] async def authorize_connection( - self, type: ConnectionType, two_way_link_type: Optional[ConnectionLinkType] = None, continuation: bool = False + self, + type: ConnectionType, + two_way_link_type: Optional[ConnectionLinkType] = None, + two_way_user_code: Optional[str] = None, + continuation: bool = False, ) -> str: """|coro| @@ -2322,6 +2347,8 @@ class Client: The type of connection to authorize. two_way_link_type: Optional[:class:`.ConnectionLinkType`] The type of two-way link to use, if any. + two_way_user_code: Optional[:class:`str`] + The device code to use for two-way linking, if any. continuation: :class:`bool` Whether this is a continuation of a previous authorization. @@ -2336,7 +2363,7 @@ class Client: The URL to redirect the user to. """ data = await self.http.authorize_connection( - str(type), str(two_way_link_type) if two_way_link_type else None, continuation=continuation + str(type), str(two_way_link_type) if two_way_link_type else None, two_way_user_code, continuation=continuation ) return data['url'] @@ -2346,6 +2373,7 @@ class Client: code: str, state: str, *, + two_way_link_code: Optional[str] = None, insecure: bool = True, friend_sync: bool = MISSING, ) -> None: @@ -2365,8 +2393,10 @@ class Client: The authorization code for the connection. state: :class:`str` The state used to authorize the connection. + two_way_link_code: Optional[:class:`str`] + The code to use for two-way linking, if any. insecure: :class:`bool` - Whether the authorization is insecure. Defaults to ``True``. + Whether the authorization is insecure. friend_sync: :class:`bool` Whether friends are synced over the connection. @@ -2384,6 +2414,7 @@ class Client: str(type), code=code, state=state, + two_way_link_code=two_way_link_code, insecure=insecure, friend_sync=friend_sync, ) @@ -2508,7 +2539,7 @@ class Client: return GroupChannel(me=self.user, data=data, state=state) # type: ignore # user is always present when logged in @overload - async def send_friend_request(self, user: BaseUser, /) -> None: + async def send_friend_request(self, user: _UserTag, /) -> None: ... @overload @@ -2519,7 +2550,7 @@ class Client: async def send_friend_request(self, username: str, discriminator: str, /) -> None: ... - async def send_friend_request(self, *args: Union[BaseUser, str]) -> None: + async def send_friend_request(self, *args: Union[_UserTag, str]) -> None: """|coro| Sends a friend request to another user. @@ -2539,7 +2570,6 @@ class Client: # Passing a username and discriminator: await client.send_friend_request('Jake', '0001') - Parameters ----------- user: Union[:class:`discord.User`, :class:`str`] @@ -2562,7 +2592,7 @@ class Client: discrim: str if len(args) == 1: user = args[0] - if isinstance(user, BaseUser): + if isinstance(user, _UserTag): user = str(user) username, discrim = user.split('#') elif len(args) == 2: @@ -2584,7 +2614,6 @@ class Client: ----------- with_team_applications: :class:`bool` Whether to include applications owned by teams you're a part of. - Defaults to ``True``. Raises ------- @@ -2621,18 +2650,18 @@ class Client: data = await state.http.get_detectable_applications() return [PartialApplication(state=state, data=d) for d in data] - async def fetch_application(self, app_id: int, /) -> Application: + async def fetch_application(self, application_id: int, /) -> Application: """|coro| Retrieves the application with the given ID. - The application must be owned by you. + The application must be owned by you or a team you are a part of. .. versionadded:: 2.0 Parameters ----------- - id: :class:`int` + application_id: :class:`int` The ID of the application to fetch. Raises @@ -2650,10 +2679,10 @@ class Client: The retrieved application. """ state = self._connection - data = await state.http.get_my_application(app_id) + data = await state.http.get_my_application(application_id) return Application(state=state, data=data) - async def fetch_partial_application(self, app_id: int, /) -> PartialApplication: + async def fetch_partial_application(self, application_id: int, /) -> PartialApplication: """|coro| Retrieves the partial application with the given ID. @@ -2662,7 +2691,7 @@ class Client: Parameters ----------- - app_id: :class:`int` + application_id: :class:`int` The ID of the partial application to fetch. Raises @@ -2678,10 +2707,10 @@ class Client: The retrieved application. """ state = self._connection - data = await state.http.get_partial_application(app_id) + data = await state.http.get_partial_application(application_id) return PartialApplication(state=state, data=data) - async def fetch_public_application(self, app_id: int, /) -> PartialApplication: + async def fetch_public_application(self, application_id: int, /, *, with_guild: bool = False) -> PartialApplication: """|coro| Retrieves the public application with the given ID. @@ -2690,8 +2719,10 @@ class Client: Parameters ----------- - app_id: :class:`int` + application_id: :class:`int` The ID of the public application to fetch. + with_guild: :class:`bool` + Whether to include the public guild of the application. Raises ------- @@ -2706,10 +2737,10 @@ class Client: The retrieved application. """ state = self._connection - data = await state.http.get_public_application(app_id) + data = await state.http.get_public_application(application_id, with_guild=with_guild) return PartialApplication(state=state, data=data) - async def fetch_public_applications(self, *app_ids: int) -> List[PartialApplication]: + async def fetch_public_applications(self, *application_ids: int) -> List[PartialApplication]: r"""|coro| Retrieves a list of public applications. Only found applications are returned. @@ -2718,13 +2749,11 @@ class Client: Parameters ----------- - \*app_ids: :class:`int` + \*application_ids: :class:`int` The IDs of the public applications to fetch. Raises ------- - TypeError - Less than 1 ID was passed. HTTPException Retrieving the applications failed. @@ -2733,20 +2762,25 @@ class Client: List[:class:`.PartialApplication`] The public applications. """ - if not app_ids: - raise TypeError('fetch_public_applications() takes at least 1 argument (0 given)') + if not application_ids: + return [] state = self._connection - data = await state.http.get_public_applications(app_ids) + data = await state.http.get_public_applications(application_ids) return [PartialApplication(state=state, data=d) for d in data] - async def teams(self) -> List[Team]: + async def teams(self, *, with_payout_account_status: bool = False) -> List[Team]: """|coro| Retrieves all the teams you're a part of. .. versionadded:: 2.0 + Parameters + ----------- + with_payout_account_status: :class:`bool` + Whether to return the payout account status of the teams. + Raises ------- HTTPException @@ -2758,7 +2792,7 @@ class Client: The teams you're a part of. """ state = self._connection - data = await state.http.get_teams() + data = await state.http.get_teams(include_payout_account_status=with_payout_account_status) return [Team(state=state, data=d) for d in data] async def fetch_team(self, team_id: int, /) -> Team: @@ -2793,7 +2827,7 @@ class Client: data = await state.http.get_team(team_id) return Team(state=state, data=data) - async def create_application(self, name: str, /): + async def create_application(self, name: str, /, *, team: Optional[Snowflake] = None) -> Application: """|coro| Creates an application. @@ -2804,6 +2838,8 @@ class Client: ---------- name: :class:`str` The name of the application. + team: :class:`~discord.abc.Snowflake` + The team to create the application under. Raises ------- @@ -2816,7 +2852,7 @@ class Client: The newly-created application. """ state = self._connection - data = await state.http.create_app(name) + data = await state.http.create_app(name, team.id if team else None) return Application(state=state, data=data) async def create_team(self, name: str, /): @@ -2844,3 +2880,1333 @@ class Client: state = self._connection data = await state.http.create_team(name) return Team(state=state, data=data) + + async def search_companies(self, query: str, /) -> List[Company]: + """|coro| + + Query your created companies. + + .. versionadded:: 2.0 + + Parameters + ----------- + query: :class:`str` + The query to search for. + + Raises + ------- + HTTPException + Searching failed. + + Returns + ------- + List[:class:`.Company`] + The companies found. + """ + state = self._connection + data = await state.http.search_companies(query) + return [Company(data=d) for d in data] + + async def activity_statistics(self) -> List[ApplicationActivityStatistics]: + """|coro| + + Retrieves the available activity usage statistics for your owned applications. + + .. versionadded:: 2.0 + + Raises + ------- + HTTPException + Retrieving the statistics failed. + + Returns + ------- + List[:class:`.ApplicationActivityStatistics`] + The activity statistics. + """ + state = self._connection + data = await state.http.get_activity_statistics() + return [ApplicationActivityStatistics(state=state, data=d) for d in data] + + async def relationship_activity_statistics(self) -> List[ApplicationActivityStatistics]: + """|coro| + + Retrieves the available activity usage statistics for your relationships' owned applications. + + .. versionadded:: 2.0 + + Raises + ------- + HTTPException + Retrieving the statistics failed. + + Returns + ------- + List[:class:`.ApplicationActivityStatistics`] + The activity statistics. + """ + state = self._connection + data = await state.http.get_global_activity_statistics() + return [ApplicationActivityStatistics(state=state, data=d) for d in data] + + async def payment_sources(self) -> List[PaymentSource]: + """|coro| + + Retrieves all the payment sources for your account. + + .. versionadded:: 2.0 + + Raises + ------- + HTTPException + Retrieving the payment sources failed. + + Returns + ------- + List[:class:`.PaymentSource`] + The payment sources. + """ + state = self._connection + data = await state.http.get_payment_sources() + return [PaymentSource(state=state, data=d) for d in data] + + async def fetch_payment_source(self, source_id: int, /) -> PaymentSource: + """|coro| + + Retrieves the payment source with the given ID. + + .. versionadded:: 2.0 + + Parameters + ----------- + source_id: :class:`int` + The ID of the payment source to fetch. + + Raises + ------- + NotFound + The payment source was not found. + HTTPException + Retrieving the payment source failed. + + Returns + ------- + :class:`.PaymentSource` + The retrieved payment source. + """ + state = self._connection + data = await state.http.get_payment_source(source_id) + return PaymentSource(state=state, data=data) + + async def create_payment_source( + self, + *, + token: str, + payment_gateway: PaymentGateway, + billing_address: BillingAddress, + billing_address_token: Optional[str] = MISSING, + return_url: Optional[str] = None, + bank: Optional[str] = None, + ) -> PaymentSource: + """|coro| + + Creates a payment source. + + This is a low-level method that requires data obtained from other APIs. + + .. versionadded:: 2.0 + + Parameters + ---------- + token: :class:`str` + The payment source token. + payment_gateway: :class:`.PaymentGateway` + The payment gateway to use. + billing_address: :class:`.BillingAddress` + The billing address to use. + billing_address_token: Optional[:class:`str`] + The billing address token. If not provided, the library will fetch it for you. + Not required for all payment gateways. + return_url: Optional[:class:`str`] + The URL to return to after the payment source is created. + bank: Optional[:class:`str`] + The bank information for the payment source. + Not required for most payment gateways. + + Raises + ------- + HTTPException + Creating the payment source failed. + + Returns + ------- + :class:`.PaymentSource` + The newly-created payment source. + """ + state = self._connection + billing_address._state = state + + data = await state.http.create_payment_source( + token=token, + payment_gateway=int(payment_gateway), + billing_address=billing_address.to_dict(), + billing_address_token=billing_address_token or await billing_address.validate() + if billing_address is not None + else None, + return_url=return_url, + bank=bank, + ) + return PaymentSource(state=state, data=data) + + async def subscriptions(self, limit: Optional[int] = None, with_inactive: bool = False) -> List[Subscription]: + """|coro| + + Retrieves all the subscriptions on your account. + + .. versionadded:: 2.0 + + Parameters + ---------- + limit: Optional[:class:`int`] + The maximum number of subscriptions to retrieve. + Defaults to all subscriptions. + with_inactive: :class:`bool` + Whether to include inactive subscriptions. + + Raises + ------- + HTTPException + Retrieving the subscriptions failed. + + Returns + ------- + List[:class:`.Subscription`] + Your account's subscriptions. + """ + state = self._connection + data = await state.http.get_subscriptions(limit=limit, include_inactive=with_inactive) + return [Subscription(state=state, data=d) for d in data] + + async def premium_guild_subscriptions(self) -> List[PremiumGuildSubscription]: + """|coro| + + Retrieves all the premium guild subscriptions (boosts) on your account. + + .. versionadded:: 2.0 + + Raises + ------- + HTTPException + Retrieving the subscriptions failed. + + Returns + ------- + List[:class:`.PremiumGuildSubscription`] + Your account's premium guild subscriptions. + """ + state = self._connection + data = await state.http.get_applied_guild_subscriptions() + return [PremiumGuildSubscription(state=state, data=d) for d in data] + + async def premium_guild_subscription_slots(self) -> List[PremiumGuildSubscriptionSlot]: + """|coro| + + Retrieves all the premium guild subscription (boost) slots available on your account. + + .. versionadded:: 2.0 + + Raises + ------- + HTTPException + Retrieving the subscriptions failed. + + Returns + ------- + List[:class:`.PremiumGuildSubscriptionSlot`] + Your account's premium guild subscription slots. + """ + state = self._connection + data = await state.http.get_guild_subscription_slots() + return [PremiumGuildSubscriptionSlot(state=state, data=d) for d in data] + + async def premium_guild_subscription_cooldown(self) -> PremiumGuildSubscriptionCooldown: + """|coro| + + Retrieves the cooldown for your premium guild subscriptions (boosts). + + .. versionadded:: 2.0 + + Raises + ------- + HTTPException + Retrieving the cooldown failed. + + Returns + ------- + :class:`.PremiumGuildSubscriptionCooldown` + Your account's premium guild subscription cooldown. + """ + state = self._connection + data = await state.http.get_guild_subscriptions_cooldown() + return PremiumGuildSubscriptionCooldown(state=state, data=data) + + async def fetch_subscription(self, subscription_id: int, /) -> Subscription: + """|coro| + + Retrieves the subscription with the given ID. + + .. versionadded:: 2.0 + + Parameters + ----------- + subscription_id: :class:`int` + The ID of the subscription to fetch. + + Raises + ------- + NotFound + The subscription was not found. + HTTPException + Retrieving the subscription failed. + + Returns + ------- + :class:`.Subscription` + The retrieved subscription. + """ + state = self._connection + data = await state.http.get_subscription(subscription_id) + return Subscription(state=state, data=data) + + async def preview_subscription( + self, + items: List[SubscriptionItem], + *, + payment_source: Optional[Snowflake] = None, + currency: str = 'usd', + trial: Optional[Snowflake] = None, + apply_entitlements: bool = True, + renewal: bool = False, + code: Optional[str] = None, + metadata: Optional[MetadataObject] = None, + guild: Optional[Snowflake] = None, + ) -> SubscriptionInvoice: + """|coro| + + Preview an invoice for the subscription with the given parameters. + + All parameters are optional and default to the current subscription values. + + .. versionadded:: 2.0 + + Parameters + ---------- + items: List[:class:`.SubscriptionItem`] + The items the previewed subscription should have. + payment_source: Optional[:class:`.PaymentSource`] + The payment source the previewed subscription should be paid with. + currency: :class:`str` + The currency the previewed subscription should be paid in. + trial: Optional[:class:`.SubscriptionTrial`] + The trial plan the previewed subscription should be on. + apply_entitlements: :class:`bool` + Whether to apply entitlements (credits) to the previewed subscription. + renewal: :class:`bool` + Whether the previewed subscription should be a renewal. + code: Optional[:class:`str`] + Unknown. + metadata: Optional[:class:`.Metadata`] + Extra metadata about the subscription. + guild: Optional[:class:`.Guild`] + The guild the previewed subscription's entitlements should be applied to. + + Raises + ------ + HTTPException + Failed to preview the invoice. + + Returns + ------- + :class:`.SubscriptionInvoice` + The previewed invoice. + """ + state = self._connection + + metadata = dict(metadata) if metadata else {} + if guild: + metadata['guild_id'] = str(guild.id) + data = await state.http.preview_subscriptions_update( + [item.to_dict(False) for item in items], + currency, + payment_source_id=payment_source.id if payment_source else None, + trial_id=trial.id if trial else None, + apply_entitlements=apply_entitlements, + renewal=renewal, + code=code, + metadata=metadata if metadata else None, + ) + return SubscriptionInvoice(None, data=data, state=state) + + async def create_subscription( + self, + items: List[SubscriptionItem], + payment_source: Snowflake, + currency: str = 'usd', + *, + trial: Optional[Snowflake] = None, + payment_source_token: Optional[str] = None, + purchase_token: Optional[str] = None, + return_url: Optional[str] = None, + gateway_checkout_context: Optional[str] = None, + code: Optional[str] = None, + metadata: Optional[MetadataObject] = None, + guild: Optional[Snowflake] = None, + ) -> Subscription: + """|coro| + + Creates a new subscription. + + .. versionadded:: 2.0 + + Parameters + ---------- + items: List[:class:`.SubscriptionItem`] + The items in the subscription. + payment_source: :class:`.PaymentSource` + The payment source to pay with. + currency: :class:`str` + The currency to pay with. + trial: Optional[:class:`.SubscriptionTrial`] + The trial to apply to the subscription. + payment_source_token: Optional[:class:`str`] + The token used to authorize with the payment source. + purchase_token: Optional[:class:`str`] + The purchase token to use. + return_url: Optional[:class:`str`] + The URL to return to after the payment is complete. + gateway_checkout_context: Optional[:class:`str`] + The current checkout context. + code: Optional[:class:`str`] + Unknown. + metadata: Optional[:class:`.Metadata`] + Extra metadata about the subscription. + guild: Optional[:class:`.Guild`] + The guild the subscription's entitlements should be applied to. + + Raises + ------- + HTTPException + Creating the subscription failed. + + Returns + ------- + :class:`.Subscription` + The newly-created subscription. + """ + state = self._connection + + metadata = dict(metadata) if metadata else {} + if guild: + metadata['guild_id'] = str(guild.id) + data = await state.http.create_subscription( + [i.to_dict(False) for i in items], + payment_source.id, + currency, + trial_id=trial.id if trial else None, + payment_source_token=payment_source_token, + return_url=return_url, + purchase_token=purchase_token, + gateway_checkout_context=gateway_checkout_context, + code=code, + metadata=metadata if metadata else None, + ) + return Subscription(state=state, data=data) + + async def payments( + self, + *, + limit: Optional[int] = 100, + before: Optional[SnowflakeTime] = None, + after: Optional[SnowflakeTime] = None, + oldest_first: Optional[bool] = None, + ) -> AsyncIterator[Payment]: + """Returns an :term:`asynchronous iterator` that enables receiving your payments. + + .. versionadded:: 2.0 + + Examples + --------- + + Usage :: + + counter = 0 + async for payment in client.payments(limit=200): + if payment.is_purchased_externally(): + counter += 1 + + Flattening into a list: :: + + payments = [payment async for payment in client.payments(limit=123)] + # payments is now a list of Payment... + + All parameters are optional. + + Parameters + ----------- + limit: Optional[:class:`int`] + The number of payments to retrieve. + If ``None``, retrieves every payment you have made. Note, however, + that this would make it a slow operation. + before: Optional[Union[:class:`~discord.abc.Snowflake`, :class:`datetime.datetime`]] + Retrieve payments before this date or payment. + 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 messages after this date or payment. + 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. + oldest_first: Optional[:class:`bool`] + If set to ``True``, return payments in oldest->newest order. Defaults to ``True`` if + ``after`` is specified, otherwise ``False``. + + Raises + ------ + HTTPException + The request to get payments failed. + + Yields + ------- + :class:`.Payment` + The payment made. + """ + + _state = self._connection + + async def _after_strategy(retrieve, after, limit): + after_id = after.id if after else None + data = await _state.http.get_payments(retrieve, after=after_id) + + if data: + if limit is not None: + limit -= len(data) + + after = Object(id=int(data[0]['id'])) + + return data, after, limit + + async def _before_strategy(retrieve, before, limit): + before_id = before.id if before else None + data = await _state.http.get_payments(retrieve, before=before_id) + + if data: + if limit is not None: + limit -= len(data) + + before = Object(id=int(data[-1]['id'])) + + return data, before, 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 oldest_first is None: + reverse = after is not None + else: + reverse = oldest_first + + after = after or OLDEST_OBJECT + predicate = None + + if reverse: + strategy, state = _after_strategy, after + if before: + predicate = lambda m: int(m['id']) < before.id + else: + strategy, state = _before_strategy, before + if after and after != OLDEST_OBJECT: + predicate = lambda m: int(m['id']) > after.id + + while True: + retrieve = min(100 if limit is None else 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) < 100: + limit = 0 + + if reverse: + data = reversed(data) + if predicate: + data = filter(predicate, data) + + for payment in data: + yield Payment(data=payment, state=_state) + + async def fetch_payment(self, payment_id: int) -> Payment: + """|coro| + + Retrieves the payment with the given ID. + + .. versionadded:: 2.0 + + Parameters + ----------- + payment_id: :class:`int` + The ID of the payment to fetch. + + Raises + ------ + HTTPException + Fetching the payment failed. + + Returns + ------- + :class:`.Payment` + The retrieved payment. + """ + state = self._connection + data = await state.http.get_payment(payment_id) + return Payment(data=data, state=state) + + async def promotions(self, claimed: bool = False) -> List[Promotion]: + """|coro| + + Retrieves all the promotions available for your account. + + .. versionadded:: 2.0 + + Parameters + ----------- + claimed: :class:`bool` + Whether to only retrieve claimed promotions. + These will have :attr:`.Promotion.claimed_at` and :attr:`.Promotion.code` set. + + Raises + ------- + HTTPException + Retrieving the promotions failed. + + Returns + ------- + List[:class:`.Promotion`] + The promotions available for you. + """ + state = self._connection + data = ( + await state.http.get_claimed_promotions(state.locale) + if claimed + else await state.http.get_promotions(state.locale) + ) + return [Promotion(state=state, data=d) for d in data] + + async def trial_offer(self) -> TrialOffer: + """|coro| + + Retrieves the current trial offer for your account. + + .. versionadded:: 2.0 + + Raises + ------- + NotFound + You do not have a trial offer. + HTTPException + Retrieving the trial offer failed. + + Returns + ------- + :class:`.TrialOffer` + The trial offer for your account. + """ + state = self._connection + data = await state.http.get_trial_offer() + return TrialOffer(data=data, state=state) + + async def pricing_promotion(self) -> Optional[PricingPromotion]: + """|coro| + + Retrieves the current localized pricing promotion for your account, if any. + + This also updates your current country code. + + .. versionadded:: 2.0 + + Raises + ------- + HTTPException + Retrieving the pricing promotion failed. + + Returns + ------- + Optional[:class:`.PricingPromotion`] + The pricing promotion for your account, if any. + """ + state = self._connection + data = await state.http.get_pricing_promotion() + state.country_code = data['country_code'] + if data['localized_pricing_promo'] is not None: + return PricingPromotion(data=data['localized_pricing_promo']) + + async def library(self) -> List[LibraryApplication]: + """|coro| + + Retrieves the applications in your library. + + .. versionadded:: 2.0 + + Raises + ------- + HTTPException + Retrieving the library failed. + + Returns + ------- + List[:class:`.LibraryApplication`] + The applications in your library. + """ + state = self._connection + data = await state.http.get_library_entries(state.country_code or 'US') + return [LibraryApplication(state=state, data=d) for d in data] + + async def entitlements( + self, *, with_sku: bool = True, with_application: bool = True, entitlement_type: Optional[EntitlementType] = None + ) -> List[Entitlement]: + """|coro| + + Retrieves all the entitlements for your account. + + .. versionadded:: 2.0 + + Parameters + ----------- + with_sku: :class:`bool` + Whether to include the SKU information in the returned entitlements. + with_application: :class:`bool` + Whether to include the application in the returned entitlements' SKUs. + The premium subscription application is always returned. + entitlement_type: Optional[:class:`.EntitlementType`] + The type of entitlement to retrieve. If ``None`` then all entitlements are returned. + + Raises + ------- + HTTPException + Retrieving the entitlements failed. + + Returns + ------- + List[:class:`.Entitlement`] + The entitlements for your account. + """ + state = self._connection + data = await state.http.get_user_entitlements( + with_sku=with_sku, + with_application=with_application, + entitlement_type=int(entitlement_type) if entitlement_type else None, + ) + return [Entitlement(state=state, data=d) for d in data] + + async def giftable_entitlements(self) -> List[Entitlement]: + """|coro| + + Retrieves the giftable entitlements for your account. + + These are entitlements you are able to gift to other users. + + .. versionadded:: 2.0 + + Raises + ------- + HTTPException + Retrieving the giftable entitlements failed. + + Returns + ------- + List[:class:`.Entitlement`] + The giftable entitlements for your account. + """ + state = self._connection + data = await state.http.get_giftable_entitlements(state.country_code or 'US') + return [Entitlement(state=state, data=d) for d in data] + + async def premium_entitlements(self, *, exclude_consumed: bool = True) -> List[Entitlement]: + """|coro| + + Retrieves the entitlements this account has granted for the premium application. + + These are the entitlements used for premium subscriptions, referred to as "Nitro Credits". + + .. versionadded:: 2.0 + + Parameters + ----------- + exclude_consumed: :class:`bool` + Whether to exclude consumed entitlements. + + Raises + ------- + HTTPException + Fetching the entitlements failed. + + Returns + -------- + List[:class:`.Entitlement`] + The entitlements retrieved. + """ + return await self.fetch_entitlements( + self._connection.premium_subscriptions_application.id, exclude_consumed=exclude_consumed + ) + + async def fetch_entitlements(self, application_id: int, /, *, exclude_consumed: bool = True) -> List[Entitlement]: + """|coro| + + Retrieves the entitlements this account has granted for the given application. + + Parameters + ----------- + application_id: :class:`int` + The ID of the application to fetch the entitlements for. + exclude_consumed: :class:`bool` + Whether to exclude consumed entitlements. + + Raises + ------- + HTTPException + Fetching the entitlements failed. + + Returns + -------- + List[:class:`.Entitlement`] + The entitlements retrieved. + """ + state = self._connection + data = await state.http.get_user_app_entitlements(application_id, exclude_consumed=exclude_consumed) + return [Entitlement(data=entitlement, state=state) for entitlement in data] + + async def fetch_gift( + self, code: Union[Gift, str], *, with_application: bool = False, with_subscription_plan: bool = True + ) -> Gift: + """|coro| + + Retrieves a gift with the given code. + + .. versionadded:: 2.0 + + Parameters + ----------- + code: Union[:class:`.Gift`, :class:`str`] + The code of the gift to retrieve. + with_application: :class:`bool` + Whether to include the application in the response's store listing. + The premium subscription application is always returned. + with_subscription_plan: :class:`bool` + Whether to include the subscription plan in the response. + + Raises + ------- + NotFound + The gift does not exist. + HTTPException + Retrieving the gift failed. + + Returns + ------- + :class:`.Gift` + The retrieved gift. + """ + state = self._connection + code = utils.resolve_gift(code) + data = await state.http.get_gift( + code, with_application=with_application, with_subscription_plan=with_subscription_plan + ) + return Gift(state=state, data=data) + + async def fetch_sku(self, sku_id: int, /, *, localize: bool = True) -> SKU: + """|coro| + + Retrieves a SKU with the given ID. + + .. versionadded:: 2.0 + + Parameters + ----------- + sku_id: :class:`int` + The ID of the SKU to retrieve. + localize: :class:`bool` + Whether to localize the SKU name and description to the current user's locale. + If ``False`` then all localizations are returned. + + Raises + ------- + NotFound + The SKU does not exist. + Forbidden + You do not have access to the SKU. + HTTPException + Retrieving the SKU failed. + + Returns + ------- + :class:`.SKU` + The retrieved SKU. + """ + state = self._connection + data = await state.http.get_sku(sku_id, country_code=state.country_code or 'US', localize=localize) + return SKU(state=state, data=data) + + async def fetch_store_listing(self, listing_id: int, /, *, localize: bool = True) -> StoreListing: + """|coro| + + Retrieves a store listing with the given ID. + + .. versionadded:: 2.0 + + Parameters + ----------- + listing_id: :class:`int` + The ID of the listing to retrieve. + localize: :class:`bool` + Whether to localize the store listings to the current user's locale. + If ``False`` then all localizations are returned. + + Raises + ------- + NotFound + The listing does not exist. + Forbidden + You do not have access to the listing. + HTTPException + Retrieving the listing failed. + + Returns + ------- + :class:`.StoreListing` + The store listing. + """ + state = self._connection + data = await state.http.get_store_listing(listing_id, country_code=state.country_code or 'US', localize=localize) + return StoreListing(state=state, data=data) + + async def fetch_published_store_listing(self, sku_id: int, /, *, localize: bool = True) -> StoreListing: + """|coro| + + Retrieves a published store listing with the given SKU ID. + + .. versionadded:: 2.0 + + Parameters + ----------- + sku_id: :class:`int` + The ID of the SKU to retrieve the listing for. + localize: :class:`bool` + Whether to localize the store listings to the current user's locale. + If ``False`` then all localizations are returned. + + Raises + ------- + NotFound + The listing does not exist or is not public. + HTTPException + Retrieving the listing failed. + + Returns + ------- + :class:`.StoreListing` + The store listing. + """ + state = self._connection + data = await state.http.get_store_listing_by_sku( + sku_id, + country_code=state.country_code or 'US', + localize=localize, + ) + return StoreListing(state=state, data=data) + + async def fetch_published_store_listings(self, application_id: int, /, localize: bool = True) -> List[StoreListing]: + """|coro| + + Retrieves all published store listings for the given application ID. + + .. versionadded:: 2.0 + + Parameters + ----------- + application_id: :class:`int` + The ID of the application to retrieve the listings for. + localize: :class:`bool` + Whether to localize the store listings to the current user's locale. + If ``False`` then all localizations are returned. + + Raises + ------- + HTTPException + Retrieving the listings failed. + + Returns + ------- + List[:class:`.StoreListing`] + The store listings. + """ + state = self._connection + data = await state.http.get_app_store_listings( + application_id, country_code=state.country_code or 'US', localize=localize + ) + return [StoreListing(state=state, data=d) for d in data] + + async def fetch_primary_store_listing(self, application_id: int, /, *, localize: bool = True) -> StoreListing: + """|coro| + + Retrieves the primary store listing for the given application ID. + + This is the public store listing of the primary SKU. + + .. versionadded:: 2.0 + + Parameters + ----------- + application_id: :class:`int` + The ID of the application to retrieve the listing for. + localize: :class:`bool` + Whether to localize the store listings to the current user's locale. + If ``False`` then all localizations are returned. + + Raises + ------ + NotFound + The application does not exist or have a primary SKU. + HTTPException + Retrieving the store listing failed. + + Returns + ------- + :class:`.StoreListing` + The retrieved store listing. + """ + state = self._connection + data = await state.http.get_app_store_listing( + application_id, country_code=state.country_code or 'US', localize=localize + ) + return StoreListing(state=state, data=data) + + async def fetch_primary_store_listings(self, *application_ids: int, localize: bool = True) -> List[StoreListing]: + r"""|coro| + + Retrieves the primary store listings for the given application IDs. + + This is the public store listing of the primary SKU. + + .. versionadded:: 2.0 + + Parameters + ----------- + \*application_ids: :class:`int` + A list of application IDs to retrieve the listings for. + localize: :class:`bool` + Whether to localize the store listings to the current user's locale. + If ``False`` then all localizations are returned. + + Raises + ------ + HTTPException + Retrieving the store listings failed. + + Returns + ------- + List[:class:`.StoreListing`] + The retrieved store listings. + """ + if not application_ids: + return [] + + state = self._connection + data = await state.http.get_apps_store_listing( + application_ids, country_code=state.country_code or 'US', localize=localize + ) + return [StoreListing(state=state, data=listing) for listing in data] + + async def premium_subscription_plans(self) -> List[SubscriptionPlan]: + """|coro| + + Retrieves all premium subscription plans. + + .. versionadded:: 2.0 + + Raises + ------ + HTTPException + Retrieving the premium subscription plans failed. + + Returns + ------- + List[:class:`.SubscriptionPlan`] + The premium subscription plans. + """ + state = self._connection + sku_ids = [v for k, v in state.premium_subscriptions_sku_ids.items() if k != 'none'] + data = await state.http.get_store_listings_subscription_plans(sku_ids) + return [SubscriptionPlan(state=state, data=d) for d in data] + + async def fetch_sku_subscription_plans( + self, + sku_id: int, + /, + *, + country_code: str = MISSING, + payment_source: Snowflake = MISSING, + with_unpublished: bool = False, + ) -> List[SubscriptionPlan]: + """|coro| + + Retrieves all subscription plans for the given SKU ID. + + .. versionadded:: 2.0 + + Parameters + ----------- + sku_id: :class:`int` + The ID of the SKU to retrieve the subscription plans for. + country_code: :class:`str` + The country code to retrieve the subscription plan prices for. + Defaults to the country code of the current user. + payment_source: :class:`.PaymentSource` + The specific payment source to retrieve the subscription plan prices for. + Defaults to all payment sources of the current user. + with_unpublished: :class:`bool` + Whether to include unpublished subscription plans. + + If ``True``, then you require access to the application. + + Raises + ------ + HTTPException + Retrieving the subscription plans failed. + + Returns + ------- + List[:class:`.SubscriptionPlan`] + The subscription plans. + """ + state = self._connection + data = await state.http.get_store_listing_subscription_plans( + sku_id, + country_code=country_code if country_code is not MISSING else None, + payment_source_id=payment_source.id if payment_source is not MISSING else None, + include_unpublished=with_unpublished, + ) + return [SubscriptionPlan(state=state, data=d) for d in data] + + async def fetch_skus_subscription_plans( + self, + *sku_ids: int, + country_code: str = MISSING, + payment_source: Snowflake = MISSING, + with_unpublished: bool = False, + ) -> List[SubscriptionPlan]: + r"""|coro| + + Retrieves all subscription plans for the given SKU IDs. + + .. versionadded:: 2.0 + + Parameters + ----------- + \*sku_ids: :class:`int` + A list of SKU IDs to retrieve the subscription plans for. + country_code: :class:`str` + The country code to retrieve the subscription plan prices for. + Defaults to the country code of the current user. + payment_source: :class:`.PaymentSource` + The specific payment source to retrieve the subscription plan prices for. + Defaults to all payment sources of the current user. + with_unpublished: :class:`bool` + Whether to include unpublished subscription plans. + + If ``True``, then you require access to the application(s). + + Raises + ------ + HTTPException + Retrieving the subscription plans failed. + + Returns + ------- + List[:class:`.SubscriptionPlan`] + The subscription plans. + """ + if not sku_ids: + return [] + + state = self._connection + data = await state.http.get_store_listings_subscription_plans( + sku_ids, + country_code=country_code if country_code is not MISSING else None, + payment_source_id=payment_source.id if payment_source is not MISSING else None, + include_unpublished=with_unpublished, + ) + return [SubscriptionPlan(state=state, data=d) for d in data] + + async def fetch_eula(self, eula_id: int, /) -> EULA: + """|coro| + + Retrieves a EULA with the given ID. + + .. versionadded:: 2.0 + + Parameters + ----------- + eula_id: :class:`int` + The ID of the EULA to retrieve. + + Raises + ------- + NotFound + The EULA does not exist. + HTTPException + Retrieving the EULA failed. + + Returns + ------- + :class:`.EULA` + The retrieved EULA. + """ + data = await self._connection.http.get_eula(eula_id) + return EULA(data=data) + + async def fetch_live_build_ids(self, *branch_ids: int) -> Dict[int, Optional[int]]: + r"""|coro| + + Retrieves the live build IDs for the given branch IDs. + + .. versionadded:: 2.0 + + Parameters + ----------- + \*branch_ids: :class:`int` + A list of branch IDs to retrieve the live build IDs for. + + Raises + ------ + HTTPException + Retrieving the live build IDs failed. + + Returns + ------- + Dict[:class:`int`, Optional[:class:`int`]] + A mapping of found branch IDs to their live build ID, if any. + """ + if not branch_ids: + return {} + + data = await self._connection.http.get_build_ids(branch_ids) + return {int(b['id']): utils._get_as_snowflake(b, 'live_build_id') for b in data} + + async def price_tiers(self) -> List[int]: + """|coro| + + Retrieves all price tiers. + + .. versionadded:: 2.0 + + Raises + ------ + HTTPException + Retrieving the price tiers failed. + + Returns + ------- + List[:class:`int`] + The price tiers. + """ + return await self._connection.http.get_price_tiers() + + async def fetch_price_tier(self, price_tier: int, /) -> Dict[str, int]: + """|coro| + + Retrieves a mapping of currency to price for the given price tier. + + .. versionadded:: 2.0 + + Parameters + ----------- + price_tier: :class:`int` + The price tier to retrieve. + + Raises + ------- + NotFound + The price tier does not exist. + HTTPException + Retrieving the price tier failed. + + Returns + ------- + Dict[:class:`str`, :class:`int`] + The retrieved price tier mapping. + """ + return await self._connection.http.get_price_tier(price_tier) + + async def premium_usage(self) -> PremiumUsage: + """|coro| + + Retrieves the usage of the premium perks on your account. + + .. versionadded:: 2.0 + + Raises + ------ + HTTPException + Retrieving the premium usage failed. + + Returns + ------- + :class:`.PremiumUsage` + The premium usage. + """ + data = await self._connection.http.get_premium_usage() + return PremiumUsage(data=data) + + async def join_active_developer_program(self, *, application: Snowflake, channel: Snowflake) -> int: + """|coro| + + Joins the current user to the active developer program. + + .. versionadded:: 2.0 + + Parameters + ----------- + application: :class:`.Application` + The application to join the active developer program with. + channel: :class:`.TextChannel` + The channel to add the developer program webhook to. + + Raises + ------- + HTTPException + Joining the active developer program failed. + + Returns + ------- + :class:`int` + The created webhook ID. + """ + data = await self._connection.http.enroll_active_developer(application.id, channel.id) + return int(data['follower']['webhook_id']) + + async def leave_active_developer_program(self) -> None: + """|coro| + + Leaves the current user from the active developer program. + This does not remove the created webhook. + + .. versionadded:: 2.0 + + Raises + ------- + HTTPException + Leaving the active developer program failed. + """ + await self._connection.http.unenroll_active_developer() diff --git a/discord/commands.py b/discord/commands.py index f241f614f..9127d2462 100644 --- a/discord/commands.py +++ b/discord/commands.py @@ -34,7 +34,7 @@ from .utils import _generate_nonce, _get_as_snowflake if TYPE_CHECKING: from .abc import Messageable, Snowflake - from .appinfo import InteractionApplication + from .appinfo import IntegrationApplication from .file import File from .guild import Guild from .interactions import Interaction @@ -80,7 +80,7 @@ class ApplicationCommand(Protocol): Whether the command is enabled in DMs. nsfw: :class:`bool` Whether the command is marked NSFW and only available in NSFW channels. - application: Optional[:class:`~discord.InteractionApplication`] + application: Optional[:class:`~discord.IntegrationApplication`] The application this command belongs to. Only available if requested. application_id: :class:`int` @@ -104,7 +104,7 @@ class ApplicationCommand(Protocol): dm_permission: bool nsfw: bool application_id: int - application: Optional[InteractionApplication] + application: Optional[IntegrationApplication] mention: str guild_id: Optional[int] @@ -206,7 +206,7 @@ class BaseCommand(ApplicationCommand, Hashable): self.type = try_enum(AppCommandType, data['type']) application = data.get('application') - self.application = state.create_interaction_application(application) if application else None + self.application = state.create_integration_application(application) if application else None self._default_member_permissions = _get_as_snowflake(data, 'default_member_permissions') self.default_permission: bool = data.get('default_permission', True) @@ -351,7 +351,7 @@ class UserCommand(BaseCommand): Whether the command is enabled in DMs. nsfw: :class:`bool` Whether the command is marked NSFW and only available in NSFW channels. - application: Optional[:class:`InteractionApplication`] + application: Optional[:class:`IntegrationApplication`] The application this command belongs to. Only available if requested. application_id: :class:`int` @@ -456,7 +456,7 @@ class MessageCommand(BaseCommand): Whether the command is enabled in DMs. nsfw: :class:`bool` Whether the command is marked NSFW and only available in NSFW channels. - application: Optional[:class:`InteractionApplication`] + application: Optional[:class:`IntegrationApplication`] The application this command belongs to. Only available if requested. application_id: :class:`int` @@ -562,7 +562,7 @@ class SlashCommand(BaseCommand, SlashMixin): Whether the command is enabled in DMs. nsfw: :class:`bool` Whether the command is marked NSFW and only available in NSFW channels. - application: Optional[:class:`InteractionApplication`] + application: Optional[:class:`IntegrationApplication`] The application this command belongs to. Only available if requested. application_id: :class:`int` @@ -807,7 +807,7 @@ class SubCommand(SlashMixin): @property def application(self): - """Optional[:class:`InteractionApplication`]: The application this command belongs to. + """Optional[:class:`IntegrationApplication`]: The application this command belongs to. Only available if requested. """ return self._parent.application diff --git a/discord/connections.py b/discord/connections.py index b625f561c..6d178b887 100644 --- a/discord/connections.py +++ b/discord/connections.py @@ -23,10 +23,11 @@ DEALINGS IN THE SOFTWARE. """ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Iterator, List, Optional, Tuple +from typing import TYPE_CHECKING, List, Optional from .enums import ConnectionType, try_enum from .integrations import Integration +from .metadata import Metadata from .utils import MISSING if TYPE_CHECKING: @@ -38,7 +39,6 @@ if TYPE_CHECKING: __all__ = ( 'PartialConnection', 'Connection', - 'ConnectionMetadata', ) @@ -79,9 +79,13 @@ class PartialConnection: Whether the connection is verified. visible: :class:`bool` Whether the connection is visible on the user's profile. + metadata: Optional[:class:`Metadata`] + Various metadata about the connection. + + The contents of this are always subject to change. """ - __slots__ = ('id', 'name', 'type', 'verified', 'visible') + __slots__ = ('id', 'name', 'type', 'verified', 'visible', 'metadata') def __init__(self, data: PartialConnectionPayload): self._update(data) @@ -112,6 +116,8 @@ class PartialConnection: self.verified: bool = data['verified'] self.visible: bool = True # If we have a partial connection, it's visible + self.metadata: Optional[Metadata] = Metadata(data['metadata']) if 'metadata' in data else None + @property def url(self) -> Optional[str]: """Optional[:class:`str`]: Returns a URL linking to the connection's profile, if available.""" @@ -174,7 +180,7 @@ class Connection(PartialConnection): Whether the connection is authorized both ways (i.e. it's both a connection and an authorization). metadata_visible: :class:`bool` Whether the connection's metadata is visible. - metadata: Optional[:class:`ConnectionMetadata`] + metadata: Optional[:class:`Metadata`] Various metadata about the connection. The contents of this are always subject to change. @@ -191,7 +197,6 @@ class Connection(PartialConnection): 'show_activity', 'two_way_link', 'metadata_visible', - 'metadata', 'access_token', 'integrations', ) @@ -209,7 +214,6 @@ class Connection(PartialConnection): self.show_activity: bool = data.get('show_activity', True) self.two_way_link: bool = data.get('two_way_link', False) self.metadata_visible: bool = bool(data.get('metadata_visibility', False)) - self.metadata: Optional[ConnectionMetadata] = ConnectionMetadata(data['metadata']) if 'metadata' in data else None # Only sometimes in the payload try: @@ -328,66 +332,3 @@ class Connection(PartialConnection): """ data = await self._state.http.get_connection_token(self.type.value, self.id) return data['access_token'] - - -class ConnectionMetadata: - """Represents a connection's metadata. - - Because of how unstable and wildly varying this metadata can be, this is a simple class that just - provides access ro the raw data using dot notation. This means if an attribute is not present, - ``None`` will be returned instead of raising an AttributeError. - - .. versionadded:: 2.0 - - .. container:: operations - - .. describe:: x == y - - Checks if two metadata objects are equal. - - .. describe:: x != y - - Checks if two metadata objects are not equal. - - .. describe:: x[key] - - Returns a metadata value if it is found, otherwise raises a :exc:`KeyError`. - - .. describe:: key in x - - Checks if a metadata value is present. - - .. describe:: iter(x) - Returns an iterator of ``(field, value)`` pairs. This allows this class - to be used as an iterable in list/dict/etc constructions. - """ - - __slots__ = () - - def __init__(self, data: Optional[dict]) -> None: - self.__dict__.update(data or {}) - - def __repr__(self) -> str: - return f'' - - def __eq__(self, other: object) -> bool: - if not isinstance(other, ConnectionMetadata): - return False - return self.__dict__ == other.__dict__ - - def __ne__(self, other: object) -> bool: - if not isinstance(other, ConnectionMetadata): - return True - return self.__dict__ != other.__dict__ - - def __iter__(self) -> Iterator[Tuple[str, Any]]: - yield from self.__dict__.items() - - def __getitem__(self, key: str) -> Any: - return self.__dict__[key] - - def __getattr__(self, attr: str) -> Any: - return None - - def __contains__(self, key: str) -> bool: - return key in self.__dict__ diff --git a/discord/entitlements.py b/discord/entitlements.py new file mode 100644 index 000000000..4bea2fc1d --- /dev/null +++ b/discord/entitlements.py @@ -0,0 +1,632 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Dolfies + +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 TYPE_CHECKING, Any, List, Optional + +from .enums import EntitlementType, GiftStyle, PremiumType, try_enum +from .flags import GiftFlags +from .mixins import Hashable +from .payments import EntitlementPayment +from .promotions import Promotion +from .store import SKU, StoreListing, SubscriptionPlan +from .subscriptions import Subscription, SubscriptionTrial +from .utils import _get_as_snowflake, parse_time, utcnow + +if TYPE_CHECKING: + from datetime import datetime + + from .abc import Snowflake + from .guild import Guild + from .state import ConnectionState + from .types.entitlements import ( + Entitlement as EntitlementPayload, + Gift as GiftPayload, + GiftBatch as GiftBatchPayload, + ) + from .user import User + +__all__ = ( + 'Entitlement', + 'Gift', + 'GiftBatch', +) + + +class Entitlement(Hashable): + """Represents a Discord entitlement. + + .. container:: operations + + .. describe:: x == y + + Checks if two entitlements are equal. + + .. describe:: x != y + + Checks if two entitlements are not equal. + + .. describe:: hash(x) + + Returns the entitlement's hash. + + .. describe:: bool(x) + + Checks if the entitlement is active. + + .. versionadded:: 2.0 + + Attributes + ---------- + id: :class:`int` + The ID of the entitlement. + type: :class:`EntitlementType` + The type of entitlement. + user_id: :class:`int` + The ID of the user the entitlement is for. + sku_id: :class:`int` + The ID of the SKU the entitlement grants. + application_id: :class:`int` + The ID of the application that owns the SKU the entitlement grants. + promotion_id: Optional[:class:`int`] + The ID of the promotion the entitlement is from. + parent_id: Optional[:class:`int`] + The ID of the entitlement's parent. + guild_id: Optional[:class:`int`] + The ID of the guild the entitlement is for. + branches: List[:class:`int`] + The IDs of the branches the entitlement grants. + gifter_id: Optional[:class:`int`] + The ID of the user that gifted the entitlement. + gift_style: Optional[:class:`GiftStyle`] + The style of the gift attached to this entitlement. + gift_batch_id: Optional[:class:`int`] + The ID of the batch the gift attached to this entitlement is from. + deleted: :class:`bool` + Whether the entitlement is deleted. + consumed: :class:`bool` + Whether the entitlement is consumed. + starts_at: Optional[:class:`datetime.datetime`] + When the entitlement period starts. + ends_at: Optional[:class:`datetime.datetime`] + When the entitlement period ends. + subscription_id: Optional[:class:`int`] + The ID of the subscription the entitlement is from. + subscription_plan: Optional[:class:`SubscriptionPlan`] + The subscription plan the entitlement is for. + + .. note:: + + This is a partial object without price information. + sku: Optional[:class:`SKU`] + The SKU the entitlement grants. + payment: Optional[:class:`EntitlementPayment`] + The payment made for the entitlement. + Not available in some contexts. + """ + + __slots__ = ( + 'id', + 'type', + 'user_id', + 'sku_id', + 'application_id', + 'promotion_id', + 'parent_id', + 'guild_id', + 'branches', + 'gifter_id', + 'gift_style', + 'gift_batch_id', + '_gift_flags', + 'deleted', + 'consumed', + 'starts_at', + 'ends_at', + 'subscription_id', + 'subscription_plan', + 'sku', + 'payment', + '_state', + ) + + def __init__(self, *, data: EntitlementPayload, state: ConnectionState): + self._state = state + self._update(data) + + def _update(self, data: EntitlementPayload): + state = self._state + + self.id: int = int(data['id']) + self.type: EntitlementType = try_enum(EntitlementType, data['type']) + self.user_id: int = int(data.get('user_id') or state.self_id) # type: ignore + self.sku_id: int = int(data['sku_id']) + self.application_id: int = int(data['application_id']) + self.promotion_id: Optional[int] = _get_as_snowflake(data, 'promotion_id') + self.parent_id: Optional[int] = _get_as_snowflake(data, 'parent_id') + self.guild_id: Optional[int] = _get_as_snowflake(data, 'guild_id') + self.branches: List[int] = [int(branch) for branch in data.get('branches', [])] + self.gifter_id: Optional[int] = _get_as_snowflake(data, 'gifter_user_id') + self.gift_style: Optional[GiftStyle] = try_enum(GiftStyle, data.get('gift_style')) + self.gift_batch_id: Optional[int] = _get_as_snowflake(data, 'gift_code_batch_id') + self._gift_flags: int = data.get('gift_code_flags', 0) + + self.deleted: bool = data.get('deleted', False) + self.consumed: bool = data.get('consumed', False) + self.starts_at: Optional[datetime] = parse_time(data.get('starts_at')) + self.ends_at: Optional[datetime] = parse_time(data.get('ends_at')) + + self.subscription_id: Optional[int] = _get_as_snowflake(data, 'subscription_id') + self.subscription_plan: Optional[SubscriptionPlan] = ( + SubscriptionPlan(data=data['subscription_plan'], state=state) if 'subscription_plan' in data else None + ) + self.sku: Optional[SKU] = SKU(data=data['sku'], state=state) if 'sku' in data else None + self.payment: Optional[EntitlementPayment] = ( + EntitlementPayment(data=data['payment'], entitlement=self) if 'payment' in data else None + ) + + def __repr__(self) -> str: + return f'' + + def __bool__(self) -> bool: + return self.is_active() + + @property + def guild(self) -> Optional[Guild]: + """:class:`Guild`: Returns the guild the entitlement is for, if accessible.""" + return self._state._get_guild(self.guild_id) + + @property + def premium_type(self) -> Optional[PremiumType]: + """Optional[:class:`PremiumType`]: The premium type this entitlement grants, if it is for a premium subscription.""" + return PremiumType.from_sku_id(self.sku_id) + + @property + def gift_flags(self) -> GiftFlags: + """:class:`GiftFlags`: Returns the flags for the gift this entitlement is attached to.""" + return GiftFlags._from_value(self._gift_flags) + + def is_giftable(self) -> bool: + """:class:`bool`: Whether the entitlement is giftable.""" + return self.type == EntitlementType.user_gift and not self.gifter_id + + def is_active(self) -> bool: + """:class:`bool`: Whether the entitlement is active and offering perks.""" + # This is a copy of the logic used in the client + + if self.is_giftable() or self.deleted: + return False # Giftable entitlements have not yet been gifted therefore are not active + if self.starts_at and self.starts_at > utcnow(): + return False # Entitlement has not started yet + if self.ends_at and self.ends_at < utcnow(): + return False # Entitlement has ended + + if self.type == EntitlementType.premium_subscription: + # Premium subscription entitlements are only active + # if the SKU is offered for free to premium subscribers + # and the user is a premium subscriber + sku = self.sku + if sku and not sku.premium: + return False + if self._state.user and not self._state.user.premium_type == PremiumType.nitro: + return False + + return True + + async def subscription(self) -> Optional[Subscription]: + """|coro| + + Retrieves the subscription this entitlement is attached to, if applicable. + + Raises + ------ + NotFound + You cannot access this subscription. + HTTPException + Fetching the subscription failed. + + Returns + ------- + Optional[:class:`Subscription`] + The retrieved subscription, if applicable. + """ + if not self.subscription_id: + return + + data = await self._state.http.get_subscription(self.subscription_id) + return Subscription(data=data, state=self._state) + + async def consume(self) -> None: + """|coro| + + Consumes the entitlement. This marks a given user entitlement as expended, + and removes the entitlement from the user's active entitlements. + + This should be called after the user has received the relevant item, + and only works on entitlements for SKUs of type :attr:`SKUType.consumable`. + + Raises + ------ + Forbidden + You do not have permissions to access this application. + HTTPException + Consuming the entitlement failed. + """ + await self._state.http.consume_app_entitlement(self.application_id, self.id) + + async def delete(self) -> None: + """|coro| + + Deletes the entitlement. This removes the entitlement from the user's + entitlements, and is irreversible. + + This is only useable on entitlements of type :attr:`EntitlementType.test_mode_purchase`. + + Raises + ------ + Forbidden + You do not have permissions to access this application. + HTTPException + Deleting the entitlement failed. + """ + await self._state.http.delete_app_entitlement(self.application_id, self.id) + + +class Gift: + """Represents a Discord gift. + + .. container:: operations + + .. describe:: x == y + + Checks if two gifts are equal. + + .. describe:: x != y + + Checks if two gifts are not equal. + + .. describe:: hash(x) + + Returns the gift's hash. + + .. versionadded:: 2.0 + + Attributes + ---------- + code: :class:`str` + The gift's code. + expires_at: Optional[:class:`datetime.datetime`] + When the gift expires. + application_id: Optional[:class:`int`] + The ID of the application that owns the SKU the gift is for. + Not available in all contexts. + batch_id: Optional[:class:`int`] + The ID of the batch the gift is from. + sku_id: :class:`int` + The ID of the SKU the gift is for. + entitlement_branches: List[:class:`int`] + A list of entitlements the gift is for. + gift_style: Optional[:class:`GiftStyle`] + The style of the gift. + max_uses: :class:`int` + The maximum number of times the gift can be used. + uses: :class:`int` + The number of times the gift has been used. + redeemed: :class:`bool` + Whether the user has redeemed the gift. + revoked: :class:`bool` + Whether the gift has been revoked. + guild_id: Optional[:class:`int`] + The ID of the guild the gift was redeemed in. + Not available in all contexts. + channel_id: Optional[:class:`int`] + The ID of the channel the gift was redeemed in. + Not available in all contexts. + store_listing: Optional[:class:`StoreListing`] + The store listing for the SKU the gift is for. + Not available in all contexts. + promotion: Optional[:class:`Promotion`] + The promotion the gift is a part of, if any. + subscription_trial: Optional[:class:`SubscriptionTrial`] + The subscription trial the gift is a part of, if any. + subscription_plan_id: Optional[:class:`int`] + The ID of the subscription plan the gift is for, if any. + subscription_plan: Optional[:class:`SubscriptionPlan`] + The subscription plan the gift is for, if any. + user: Optional[:class:`User`] + The user who created the gift, if applicable. + """ + + __slots__ = ( + 'code', + 'expires_at', + 'application_id', + 'batch_id', + 'sku_id', + 'entitlement_branches', + 'gift_style', + '_flags', + 'max_uses', + 'uses', + 'redeemed', + 'revoked', + 'guild_id', + 'channel_id', + 'store_listing', + 'promotion', + 'subscription_trial', + 'subscription_plan_id', + 'subscription_plan', + 'user', + '_state', + ) + + def __init__(self, *, data: GiftPayload, state: ConnectionState) -> None: + self._state = state + self._update(data) + + def _update(self, data: GiftPayload) -> None: + state = self._state + + self.code: str = data['code'] + self.expires_at: Optional[datetime] = parse_time(data.get('expires_at')) + self.application_id: Optional[int] = _get_as_snowflake(data, 'application_id') + self.batch_id: Optional[int] = _get_as_snowflake(data, 'batch_id') + self.subscription_plan_id: Optional[int] = _get_as_snowflake(data, 'subscription_plan_id') + self.sku_id: int = int(data['sku_id']) + self.entitlement_branches: List[int] = [int(x) for x in data.get('entitlement_branches', [])] + self.gift_style: Optional[GiftStyle] = try_enum(GiftStyle, data['gift_style']) if data.get('gift_style') else None # type: ignore + self._flags: int = data.get('flags', 0) + + self.max_uses: int = data.get('max_uses', 0) + self.uses: int = data.get('uses', 0) + self.redeemed: bool = data.get('redeemed', False) + self.revoked: bool = data.get('revoked', False) + + self.guild_id: Optional[int] = _get_as_snowflake(data, 'guild_id') + self.channel_id: Optional[int] = _get_as_snowflake(data, 'channel_id') + + self.store_listing: Optional[StoreListing] = ( + StoreListing(data=data['store_listing'], state=state) if 'store_listing' in data else None + ) + self.promotion: Optional[Promotion] = Promotion(data=data['promotion'], state=state) if 'promotion' in data else None + self.subscription_trial: Optional[SubscriptionTrial] = ( + SubscriptionTrial(data['subscription_trial']) if 'subscription_trial' in data else None + ) + self.subscription_plan: Optional[SubscriptionPlan] = ( + SubscriptionPlan(data=data['subscription_plan'], state=state) if 'subscription_plan' in data else None + ) + self.user: Optional[User] = self._state.create_user(data['user']) if 'user' in data else None + + def __repr__(self) -> str: + return f'' + + def __eq__(self, other: Any) -> bool: + return isinstance(other, Gift) and other.code == self.code + + def __ne__(self, other: Any) -> bool: + if isinstance(other, Gift): + return other.code != self.code + return True + + def __hash__(self) -> int: + return hash(self.code) + + @property + def id(self) -> str: + """:class:`str`: Returns the code portion of the gift.""" + return self.code + + @property + def url(self) -> str: + """:class:`str`: Returns the gift's URL.""" + return f'https://discord.gift/{self.code}' + + @property + def remaining_uses(self) -> int: + """:class:`int`: Returns the number of remaining uses for the gift.""" + return self.max_uses - self.uses + + @property + def flags(self) -> GiftFlags: + """:class:`GiftFlags`: Returns the gift's flags.""" + return GiftFlags._from_value(self._flags) + + @property + def premium_type(self) -> Optional[PremiumType]: + """Optional[:class:`PremiumType`]: The premium type this gift grants, if it is for a premium subscription.""" + return PremiumType.from_sku_id(self.sku_id) if self.is_subscription() else None + + def is_claimed(self) -> bool: + """:class:`bool`: Checks if the gift has been used up.""" + return self.uses >= self.max_uses if self.max_uses else False + + def is_expired(self) -> bool: + """:class:`bool`: Checks if the gift has expired.""" + return self.expires_at < utcnow() if self.expires_at else False + + def is_subscription(self) -> bool: + """:class:`bool`: Checks if the gift is for a subscription.""" + return self.subscription_plan_id is not None + + def is_premium_subscription(self) -> bool: + """:class:`bool`: Checks if the gift is for a premium subscription.""" + return self.is_subscription() and self.application_id == self._state.premium_subscriptions_application.id + + async def redeem( + self, + payment_source: Optional[Snowflake] = None, + *, + channel: Optional[Snowflake] = None, + gateway_checkout_context: Optional[str] = None, + ) -> Entitlement: + """|coro| + + Redeems the gift. + + Parameters + ---------- + payment_source: Optional[:class:`PaymentSource`] + The payment source to use for the redemption. + Only required if the gift's :attr:`flags` have :attr:`GiftFlags.payment_source_required` set to ``True``. + channel: Optional[Union[:class:`TextChannel`, :class:`VoiceChannel`, :class:`Thread`, :class:`DMChannel`, :class:`GroupChannel`]] + The channel to redeem the gift in. This is usually the channel the gift was sent in. + While this is optional, it is recommended to pass this in. + gateway_checkout_context: Optional[:class:`str`] + The current checkout context. + + Raises + ------ + HTTPException + The gift failed to redeem. + + Returns + ------- + :class:`Entitlement` + The entitlement that was created from redeeming the gift. + """ + data = await self._state.http.redeem_gift( + self.code, + payment_source.id if payment_source else None, + channel.id if channel else None, + gateway_checkout_context, + ) + return Entitlement(data=data, state=self._state) + + async def delete(self) -> None: + """|coro| + + Revokes the gift. + + This is only possible for gifts the current account has created. + + Raises + ------ + NotFound + The owned gift was not found. + HTTPException + The gift failed to delete. + """ + await self._state.http.delete_gift(self.code) + + +class GiftBatch(Hashable): + """Represents a batch of gifts for an SKU. + + .. container:: operations + + .. describe:: x == y + + Checks if two gift batches are equal. + + .. describe:: x != y + + Checks if two gift batches are not equal. + + .. describe:: hash(x) + + Returns the gift batch's hash. + + .. describe:: str(x) + + Returns the gift batch's description. + + .. versionadded:: 2.0 + + Attributes + ----------- + id: :class:`int` + The ID of the gift batch. + application_id: :class:`int` + The ID of the application the gift batch is for. + sku_id: :class:`int` + The ID of the SKU the gift batch is for. + amount: :class:`int` + The amount of gifts in the batch. + description: :class:`str` + The description of the gift batch. + entitlement_branches: List[:class:`int`] + The entitlement branches the gift batch is for. + entitlement_starts_at: Optional[:class:`datetime.datetime`] + When the entitlement is valid from. + entitlement_ends_at: Optional[:class:`datetime.datetime`] + When the entitlement is valid until. + """ + + __slots__ = ( + 'id', + 'application_id', + 'sku_id', + 'amount', + 'description', + 'entitlement_branches', + 'entitlement_starts_at', + 'entitlement_ends_at', + '_state', + ) + + def __init__(self, *, data: GiftBatchPayload, state: ConnectionState, application_id: int) -> None: + self._state: ConnectionState = state + self.id: int = int(data['id']) + self.application_id = application_id + self.sku_id: int = int(data['sku_id']) + self.amount: int = data['amount'] + self.description: str = data.get('description', '') + self.entitlement_branches: List[int] = [int(branch) for branch in data.get('entitlement_branches', [])] + self.entitlement_starts_at: Optional[datetime] = parse_time(data.get('entitlement_starts_at')) + self.entitlement_ends_at: Optional[datetime] = parse_time(data.get('entitlement_ends_at')) + + def __repr__(self) -> str: + return f'' + + def __str__(self) -> str: + return self.description + + def is_valid(self) -> bool: + """:class:`bool`: Checks if the gift batch is valid.""" + if self.entitlement_starts_at and self.entitlement_starts_at > utcnow(): + return False + if self.entitlement_ends_at and self.entitlement_ends_at < utcnow(): + return False + return True + + async def download(self) -> bytes: + """|coro| + + Returns the gifts in the gift batch in CSV format. + + Raises + ------- + Forbidden + You do not have permissions to download the batch. + HTTPException + Downloading the batch failed. + + Returns + ------- + :class:`bytes` + The report content. + """ + return await self._state.http.get_gift_batch_csv(self.application_id, self.id) diff --git a/discord/enums.py b/discord/enums.py index 795921ace..ced3c7c06 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -42,7 +42,10 @@ __all__ = ( 'ActivityType', 'NotificationLevel', 'HighlightLevel', - 'TeamMembershipState', + 'ApplicationMembershipState', + 'PayoutAccountStatus', + 'PayoutStatus', + 'PayoutReportType', 'WebhookType', 'ExpireBehaviour', 'ExpireBehavior', @@ -53,6 +56,7 @@ __all__ = ( 'ComponentType', 'ButtonStyle', 'TextStyle', + 'GiftStyle', 'PrivacyLevel', 'InteractionType', 'NSFWLevel', @@ -76,12 +80,33 @@ __all__ = ( 'ApplicationVerificationState', 'StoreApplicationState', 'RPCApplicationState', + 'ApplicationDiscoverabilityState', 'InviteType', 'ScheduledEventStatus', 'ScheduledEventEntityType', 'ApplicationType', + 'EmbeddedActivityPlatform', + 'EmbeddedActivityOrientation', 'ConnectionType', 'ConnectionLinkType', + 'PaymentSourceType', + 'PaymentGateway', + 'SubscriptionType', + 'SubscriptionStatus', + 'SubscriptionInvoiceStatus', + 'SubscriptionDiscountType', + 'SubscriptionInterval', + 'SubscriptionPlanPurchaseType', + 'PaymentStatus', + 'ApplicationAssetType', + 'SKUType', + 'SKUAccessLevel', + 'SKUFeature', + 'SKUGenre', + 'OperatingSystem', + 'ContentRatingAgency', + 'Distributor', + 'EntitlementType', ) if TYPE_CHECKING: @@ -564,6 +589,7 @@ class UserFlags(Enum): bot_http_interactions = 524288 spammer = 1048576 disable_premium = 2097152 + quarantined = 17592186044416 class ActivityType(Enum): @@ -585,16 +611,63 @@ class HypeSquadHouse(Enum): balance = 3 -class PremiumType(Enum, comparable=True): +class PremiumType(Enum): + none = 0 nitro_classic = 1 nitro = 2 + nitro_basic = 3 - -class TeamMembershipState(Enum, comparable=True): + @classmethod + def from_sku_id(cls, sku_id: int) -> Optional[PremiumType]: + if sku_id == 628379670982688768: + return cls.none + elif sku_id == 521846918637420545: + return cls.nitro_classic + elif sku_id in (521842865731534868, 521847234246082599): + return cls.nitro + elif sku_id == 978380684370378762: + return cls.nitro_basic + + +class ApplicationMembershipState(Enum, comparable=True): invited = 1 accepted = 2 +class PayoutAccountStatus(Enum): + unsubmitted = 1 + pending = 2 + action_required = 3 + active = 4 + blocked = 5 + suspended = 6 + + +class PayoutStatus(Enum): + open = 1 + paid = 2 + pending = 3 + manual = 4 + canceled = 5 + cancelled = 5 + deferred = 6 + deferred_internal = 7 + processing = 8 + error = 9 + rejected = 10 + risk_review = 11 + submitted = 12 + pending_funds = 13 + + +class PayoutReportType(Enum): + by_sku = 'sku' + by_transaction = 'transaction' + + def __str__(self) -> str: + return self.value + + class WebhookType(Enum): incoming = 1 channel_follower = 2 @@ -605,6 +678,9 @@ class ExpireBehaviour(Enum): remove_role = 0 kick = 1 + def __int__(self) -> int: + return self.value + ExpireBehavior = ExpireBehaviour @@ -661,7 +737,9 @@ class RequiredActionType(Enum): verify_phone = 'REQUIRE_VERIFIED_PHONE' verify_email = 'REQUIRE_VERIFIED_EMAIL' complete_captcha = 'REQUIRE_CAPTCHA' - accept_terms = 'AGREEMENTS' + update_agreements = 'AGREEMENTS' + acknowledge_tos_update = 'TOS_UPDATE_ACKNOWLEDGMENT' + none = None class InviteTarget(Enum): @@ -735,6 +813,15 @@ class TextStyle(Enum): return self.value +class GiftStyle(Enum): + snowglobe = 1 + box = 2 + cup = 3 + + def __int__(self) -> int: + return self.value + + class PrivacyLevel(Enum): public = 1 closed = 2 @@ -791,13 +878,54 @@ class RPCApplicationState(Enum, comparable=True): rejected = 4 +class ApplicationDiscoverabilityState(Enum, comparable=True): + ineligible = 1 + not_discoverable = 2 + discoverable = 3 + featureable = 4 + blocked = 5 + + +class ApplicationBuildStatus(Enum): + created = 'CREATED' + uploading = 'UPLOADING' + uploaded = 'UPLOADED' + invalid = 'INVALID' + validating = 'VALIDATING' + corrupted = 'CORRUPTED' + ready = 'READY' + + def __str__(self) -> str: + return self.value + + class ApplicationType(Enum): - none = None game = 1 music = 2 ticketed_events = 3 guild_role_subscriptions = 4 + def __int__(self) -> int: + return self.value + + +class EmbeddedActivityPlatform(Enum): + web = 'web' + ios = 'ios' + android = 'android' + + def __str__(self) -> str: + return self.value + + +class EmbeddedActivityOrientation(Enum): + unlocked = 1 + portrait = 2 + landscape = 3 + + def __int__(self) -> int: + return self.value + T = TypeVar('T') @@ -918,6 +1046,338 @@ class ConnectionLinkType(Enum): return self.value +class PaymentSourceType(Enum): + unknown = 0 + card = 1 + paypal = 2 + giropay = 3 + sofort = 4 + przzelewy24 = 5 + sepa_debit = 6 + paysafecard = 7 + gcash = 8 + grabpay = 9 + momo_wallet = 10 + venmo = 11 + gopay_wallet = 12 + kakaopay = 13 + bancontact = 14 + eps = 15 + ideal = 16 + payment_request = 99 + + +class PaymentGateway(Enum): + stripe = 1 + braintree = 2 + apple = 3 + google = 4 + adyen = 5 + apple_pay = 6 + + def __int__(self) -> int: + return self.value + + +class SubscriptionType(Enum): + premium = 1 + guild = 2 + application = 3 + + +class SubscriptionStatus(Enum): + unpaid = 0 + active = 1 + past_due = 2 + canceled = 3 + cancelled = 3 + ended = 4 + inactive = 5 + account_hold = 6 + + def __int__(self) -> int: + return self.value + + +class SubscriptionInvoiceStatus(Enum, comparable=True): + open = 1 + paid = 2 + void = 3 + uncollectible = 4 + + +class SubscriptionDiscountType(Enum): + subscription_plan = 1 + entitlement = 2 + premium_legacy_upgrade_promotion = 3 + premium_trial = 4 + + +class SubscriptionInterval(Enum): + month = 1 + year = 2 + day = 3 + + +class SubscriptionPlanPurchaseType(Enum): + default = 0 + gift = 1 + sale = 2 + nitro_classic = 3 + nitro = 4 + + +class PaymentStatus(Enum): + pending = 0 + completed = 1 + failed = 2 + reversed = 3 + refunded = 4 + canceled = 5 + cancelled = 5 + + +class ApplicationAssetType(Enum): + one = 1 + two = 2 + + def __int__(self) -> int: + return self.value + + +class SKUType(Enum): + durable_primary = 1 + durable = 2 + consumable = 3 + bundle = 4 + subscription = 5 + group = 6 + + def __int__(self) -> int: + return self.value + + +class SKUAccessLevel(Enum, comparable=True): + full = 1 + early_access = 2 + vip_access = 3 + + def __int__(self) -> int: + return self.value + + +class SKUFeature(Enum): + single_player = 1 + online_multiplayer = 2 + local_multiplayer = 3 + pvp = 4 + local_coop = 5 + cross_platform = 6 + rich_presence = 7 + discord_game_invites = 8 + spectator_mode = 9 + controller_support = 10 + cloud_saves = 11 + online_coop = 12 + secure_networking = 13 + + def __int__(self) -> int: + return self.value + + +class SKUGenre(Enum): + action = 1 + action_adventure = 9 + action_rpg = 2 + adventure = 8 + artillery = 50 + baseball = 34 + basketball = 35 + billiards = 36 + bowling = 37 + boxing = 38 + brawler = 3 + card_game = 58 + driving_racing = 16 + dual_joystick_shooter = 27 + dungeon_crawler = 21 + education = 59 + fighting = 56 + fishing = 32 + fitness = 60 + flight_simulator = 29 + football = 39 + four_x = 49 + fps = 26 + gambling = 61 + golf = 40 + hack_and_slash = 4 + hockey = 41 + life_simulator = 31 + light_gun = 24 + massively_multiplayer = 18 + metroidvania = 10 + mmorpg = 19 + moba = 55 + music_rhythm = 62 + open_world = 11 + party_mini_game = 63 + pinball = 64 + platformer = 5 + psychological_horror = 12 + puzzle = 57 + rpg = 22 + role_playing = 20 + rts = 51 + sandbox = 13 + shooter = 23 + shoot_em_up = 25 + simulation = 28 + skateboarding_skating = 42 + snowboarding_skiing = 43 + soccer = 44 + sports = 33 + stealth = 6 + strategy = 48 + surfing_wakeboarding = 46 + survival = 7 + survival_horror = 14 + tower_defense = 52 + track_field = 45 + train_simulator = 30 + trivia_board_game = 65 + turn_based_strategy = 53 + vehicular_combat = 17 + visual_novel = 15 + wargame = 54 + wrestling = 47 + + def __int__(self) -> int: + return self.value + + +class OperatingSystem(Enum): + windows = 1 + mac = 2 + linux = 3 + + +class ContentRatingAgency(Enum): + esrb = 1 + pegi = 2 + + +class ESRBRating(Enum): + everyone = 1 + everyone_ten_plus = 2 + teen = 3 + mature = 4 + adults_only = 5 + rating_pending = 6 + + def __int__(self) -> int: + return self.value + + +class PEGIRating(Enum): + three = 1 + seven = 2 + twelve = 3 + sixteen = 4 + eighteen = 5 + + def __int__(self) -> int: + return self.value + + +class ESRBContentDescriptor(Enum): + alcohol_reference = 1 + animated_blood = 2 + blood = 3 + blood_and_gore = 4 + cartoon_violence = 5 + comic_mischief = 6 + crude_humor = 7 + drug_reference = 8 + fantasy_violence = 9 + intense_violence = 10 + language = 11 + lyrics = 12 + mature_humor = 13 + nudity = 14 + partial_nudity = 15 + real_gambling = 16 + sexual_content = 17 + sexual_themes = 18 + sexual_violence = 19 + simulated_gambling = 20 + strong_language = 21 + strong_lyrics = 22 + strong_sexual_content = 23 + suggestive_themes = 24 + tobacco_reference = 25 + use_of_alcohol = 26 + use_of_drugs = 27 + use_of_tobacco = 28 + violence = 29 + violent_references = 30 + in_game_purchases = 31 + users_interact = 32 + shares_location = 33 + unrestricted_internet = 34 + mild_blood = 35 + mild_cartoon_violence = 36 + mild_fantasy_violence = 37 + mild_language = 38 + mild_lyrics = 39 + mild_sexual_themes = 40 + mild_suggestive_themes = 41 + mild_violence = 42 + animated_violence = 43 + + def __int__(self) -> int: + return self.value + + +class PEGIContentDescriptor(Enum): + violence = 1 + bad_language = 2 + fear = 3 + gambling = 4 + sex = 5 + drugs = 6 + discrimination = 7 + + def __int__(self) -> int: + return self.value + + +class Distributor(Enum): + discord = 'discord' + steam = 'steam' + twitch = 'twitch' + uplay = 'uplay' + battle_net = 'battlenet' + origin = 'origin' + gog = 'gog' + epic_games = 'epic' + google_play = 'google_play' + + +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 + + def __int__(self) -> int: + return self.value + + 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/errors.py b/discord/errors.py index a4abef773..2519e5678 100644 --- a/discord/errors.py +++ b/discord/errors.py @@ -25,6 +25,8 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations from typing import Dict, List, Optional, TYPE_CHECKING, Any, Tuple, Union +from .utils import _get_as_snowflake + if TYPE_CHECKING: from aiohttp import ClientResponse, ClientWebSocketResponse @@ -79,6 +81,11 @@ class GatewayNotFound(DiscordException): def _flatten_error_dict(d: Dict[str, Any], key: str = '') -> Dict[str, str]: items: List[Tuple[str, str]] = [] + + if '_errors' in d: + items.append(('miscallenous', ' '.join(x.get('message', '') for x in d['_errors']))) + d.pop('_errors') + for k, v in d.items(): new_key = key + '.' + k if key else k @@ -112,6 +119,12 @@ class HTTPException(DiscordException): The Discord specific error code for the failure. json: :class:`dict` The raw error JSON. + + .. versionadded:: 2.0 + payment_id: Optional[:class:`int`] + The ID of the payment that requires verification to continue. + + .. versionadded:: 2.0 """ def __init__(self, response: _ResponseType, message: Optional[Union[str, Dict[str, Any]]]): @@ -119,6 +132,8 @@ class HTTPException(DiscordException): self.status: int = response.status # type: ignore # This attribute is filled by the library even if using requests self.code: int self.text: str + self.json: Dict[str, Any] + self.payment_id: Optional[int] if isinstance(message, dict): self.json = message self.code = message.get('code', 0) @@ -130,9 +145,12 @@ class HTTPException(DiscordException): self.text = base + '\n' + helpful else: self.text = base + self.payment_id = _get_as_snowflake(message, 'payment_id') else: self.text = message or '' self.code = 0 + self.json = {} + self.payment_id = None fmt = '{0.status} {0.reason} (error code: {1})' if len(self.text): diff --git a/discord/file.py b/discord/file.py index ba390b982..fcd847455 100644 --- a/discord/file.py +++ b/discord/file.py @@ -23,12 +23,14 @@ DEALINGS IN THE SOFTWARE. """ from __future__ import annotations -from typing import Any, Dict, Optional, Tuple, Union -import os +from base64 import b64encode +from hashlib import md5 import io +import os +from typing import Any, Dict, Optional, Tuple, Union -from .utils import MISSING +from .utils import MISSING, cached_slot_property # fmt: off __all__ = ( @@ -77,7 +79,7 @@ class File: .. versionadded:: 2.0 """ - __slots__ = ('fp', '_filename', 'spoiler', 'description', '_original_pos', '_owner', '_closer') + __slots__ = ('fp', '_filename', 'spoiler', 'description', '_original_pos', '_owner', '_closer', '_cs_md5') def __init__( self, @@ -129,6 +131,13 @@ class File: def filename(self, value: str) -> None: self._filename, self.spoiler = _strip_spoiler(value) + @cached_slot_property('_cs_md5') + def md5(self) -> str: + try: + return b64encode(md5(self.fp.read()).digest()).decode('utf-8') + finally: + self.reset() + def reset(self, *, seek: Union[int, bool] = True) -> None: # The `seek` parameter is needed because # the retry-loop is iterated over multiple times diff --git a/discord/flags.py b/discord/flags.py index e20d2bb49..527edd277 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -42,6 +42,13 @@ __all__ = ( 'ChannelFlags', 'PremiumUsageFlags', 'PurchasedFlags', + 'PaymentSourceFlags', + 'SKUFlags', + 'PaymentFlags', + 'PromotionFlags', + 'GiftFlags', + 'LibraryApplicationFlags', + 'ApplicationDiscoveryFlags', ) BF = TypeVar('BF', bound='BaseFlags') @@ -379,17 +386,14 @@ class PublicUserFlags(BaseFlags): @flag_value def bug_hunter(self): - """:class:`bool`: Returns ``True`` if the user is a level 1 Bug Hunter - - There is an alias for this called :attr:`bug_hunter_level_1`. - """ + """:class:`bool`: Returns ``True`` if the user is a level 1 Bug Hunter.""" return UserFlags.bug_hunter.value @alias_flag_value def bug_hunter_level_1(self): - """:class:`bool`: Returns ``True`` if the user is a Bug Hunter + """:class:`bool`: An alias for :attr:`bug_hunter`. - This is an alias of :attr:`bug_hunter`. + .. versionadded:: 2.0 """ return UserFlags.bug_hunter_level_1.value @@ -495,7 +499,7 @@ class PrivateUserFlags(PublicUserFlags): Returns an iterator of ``(name, value)`` pairs. This allows it to be, for example, constructed as a dict or a list of pairs. - Note that aliases are not shown. + Note that aliases or inherited flags are not shown. .. note:: These are only available on your own user flags. @@ -514,7 +518,7 @@ class PrivateUserFlags(PublicUserFlags): @flag_value def premium_promo_dismissed(self): - """:class:`bool`: Returns ``True`` if the user has dismissed the premium promo.""" + """:class:`bool`: Returns ``True`` if the user has dismissed the current premium promotion.""" return UserFlags.premium_promo_dismissed.value @flag_value @@ -542,6 +546,11 @@ class PrivateUserFlags(PublicUserFlags): """:class:`bool`: Returns ``True`` if the user bought premium but has it manually disabled.""" return UserFlags.disable_premium.value + @flag_value + def quarantined(self): + """:class:`bool`: Returns ``True`` if the user is quarantined.""" + return UserFlags.quarantined.value + @fill_with_flags() class PremiumUsageFlags(BaseFlags): @@ -578,17 +587,17 @@ class PremiumUsageFlags(BaseFlags): @flag_value def premium_discriminator(self): - """:class:`bool`: Returns ``True`` if the user utilized premium discriminators.""" + """:class:`bool`: Returns ``True`` if the user has utilized premium discriminators.""" return 1 << 0 @flag_value def animated_avatar(self): - """:class:`bool`: Returns ``True`` if the user utilized animated avatars.""" + """:class:`bool`: Returns ``True`` if the user has utilized animated avatars.""" return 1 << 1 @flag_value def profile_banner(self): - """:class:`bool`: Returns ``True`` if the user utilized profile banners.""" + """:class:`bool`: Returns ``True`` if the user has utilized profile banners.""" return 1 << 2 @@ -627,19 +636,24 @@ class PurchasedFlags(BaseFlags): @flag_value def nitro_classic(self): - """:class:`bool`: Returns ``True`` if the user has previously purchased Nitro classic.""" + """:class:`bool`: Returns ``True`` if the user has purchased Nitro classic.""" return 1 << 0 @flag_value def nitro(self): - """:class:`bool`: Returns ``True`` if the user has previously purchased Nitro.""" + """:class:`bool`: Returns ``True`` if the user has purchased Nitro.""" return 1 << 1 @flag_value def guild_boost(self): - """:class:`bool`: Returns ``True`` if the user has previously purchased a guild boost.""" + """:class:`bool`: Returns ``True`` if the user has purchased a guild boost.""" return 1 << 2 + @flag_value + def nitro_basic(self): + """:class:`bool`: Returns ``True`` if the user has purchased Nitro basic.""" + return 1 << 3 + @fill_with_flags() class MemberCacheFlags(BaseFlags): @@ -772,6 +786,53 @@ class ApplicationFlags(BaseFlags): rather than using this raw value. """ + __slots__ = () + + @flag_value + def embedded_released(self): + """:class:`bool`: Returns ``True`` if the embedded application is released to the public.""" + return 1 << 1 + + @flag_value + def managed_emoji(self): + """:class:`bool`: Returns ``True`` if the application has the ability to create Twitch-style emotes.""" + return 1 << 2 + + @flag_value + def embedded_iap(self): + """:class:`bool`: Returns ``True`` if the application has the ability to use embedded in-app purchases.""" + return 1 << 3 + + @flag_value + def group_dm_create(self): + """:class:`bool`: Returns ``True`` if the application has the ability to create group DMs.""" + return 1 << 4 + + @flag_value + def rpc_private_beta(self): + """:class:`bool`: Returns ``True`` if the application has the ability to access the client RPC server.""" + return 1 << 5 + + @flag_value + def allow_assets(self): + """:class:`bool`: Returns ``True`` if the application has the ability to use activity assets.""" + return 1 << 8 + + @flag_value + def allow_activity_action_spectate(self): + """:class:`bool`: Returns ``True`` if the application has the ability to enable spectating activities.""" + return 1 << 9 + + @flag_value + def allow_activity_action_join_request(self): + """:class:`bool`: Returns ``True`` if the application has the ability to enable activity join requests.""" + return 1 << 10 + + @flag_value + def rpc_has_connected(self): + """:class:`bool`: Returns ``True`` if the application has accessed the client RPC server before.""" + return 1 << 11 + @flag_value def gateway_presence(self): """:class:`bool`: Returns ``True`` if the application is verified and is allowed to @@ -779,11 +840,6 @@ class ApplicationFlags(BaseFlags): """ return 1 << 12 - @alias_flag_value - def presence(self): - """:class:`bool`: Alias for :attr:`gateway_presence`.""" - return 1 << 12 - @flag_value def gateway_presence_limited(self): """:class:`bool`: Returns ``True`` if the application is allowed to receive @@ -791,11 +847,6 @@ class ApplicationFlags(BaseFlags): """ return 1 << 13 - @alias_flag_value - def presence_limited(self): - """:class:`bool`: Alias for :attr:`gateway_presence_limited`.""" - return 1 << 13 - @flag_value def gateway_guild_members(self): """:class:`bool`: Returns ``True`` if the application is verified and is allowed to @@ -803,11 +854,6 @@ class ApplicationFlags(BaseFlags): """ return 1 << 14 - @alias_flag_value - def guild_members(self): - """:class:`bool`: Alias for :attr:`gateway_guild_members`.""" - return 1 << 14 - @flag_value def gateway_guild_members_limited(self): """:class:`bool`: Returns ``True`` if the application is allowed to receive full @@ -815,11 +861,6 @@ class ApplicationFlags(BaseFlags): """ return 1 << 15 - @alias_flag_value - def guild_members_limited(self): - """:class:`bool`: Alias for :attr:`gateway_guild_members_limited`.""" - return 1 << 15 - @flag_value def verification_pending_guild_limit(self): """:class:`bool`: Returns ``True`` if the application is currently pending verification @@ -838,31 +879,28 @@ class ApplicationFlags(BaseFlags): receive message content.""" return 1 << 18 - @alias_flag_value - def message_content(self): - """:class:`bool`: Alias for :attr:`gateway_message_content`.""" - return 1 << 18 - @flag_value def gateway_message_content_limited(self): """:class:`bool`: Returns ``True`` if the application is allowed to read message content in guilds.""" return 1 << 19 - @alias_flag_value - def message_content_limited(self): - """:class:`bool`: Alias for :attr:`gateway_message_content_limited`.""" - return 1 << 19 - @flag_value def embedded_first_party(self): """:class:`bool`: Returns ``True`` if the embedded application is published by Discord.""" return 1 << 20 @flag_value - def embedded_released(self): - """:class:`bool`: Returns ``True`` if the embedded application is released to the public.""" - return 1 << 1 + def application_command_badge(self): + """:class:`bool`: Returns ``True`` if the application has registered global application commands.""" + return 1 << 23 + + @flag_value + def active(self): + """:class:`bool`: Returns ``True`` if the application is considered active. + This means that it has had any global command executed in the past 30 days. + """ + return 1 << 24 @fill_with_flags() @@ -895,7 +933,485 @@ class ChannelFlags(BaseFlags): rather than using this raw value. """ + __slots__ = () + @flag_value def pinned(self): """:class:`bool`: Returns ``True`` if the thread is pinned to the forum channel.""" return 1 << 1 + + +@fill_with_flags() +class PaymentSourceFlags(BaseFlags): + r"""Wraps up the Discord payment source flags. + + .. container:: operations + + .. describe:: x == y + + Checks if two PaymentSourceFlags are equal. + .. describe:: x != y + + Checks if two PaymentSourceFlags are not equal. + .. describe:: hash(x) + + Return the flag's hash. + .. describe:: iter(x) + + Returns an iterator of ``(name, value)`` pairs. This allows it + to be, for example, constructed as a dict or a list of pairs. + Note that aliases are not shown. + + .. versionadded:: 2.0 + + Attributes + ----------- + value: :class:`int` + The raw value. This value is a bit array field of a 53-bit integer + representing the currently available flags. You should query + flags via the properties rather than using this raw value. + """ + + __slots__ = () + + @flag_value + def new(self): + """:class:`bool`: Returns ``True`` if the payment source is new.""" + return 1 << 0 + + @flag_value + def unknown(self): + return 1 << 1 + + +@fill_with_flags() +class SKUFlags(BaseFlags): + r"""Wraps up the Discord SKU flags. + + .. container:: operations + + .. describe:: x == y + + Checks if two SKUFlags are equal. + .. describe:: x != y + + Checks if two SKUFlags are not equal. + .. describe:: hash(x) + + Return the flag's hash. + .. describe:: iter(x) + + Returns an iterator of ``(name, value)`` pairs. This allows it + to be, for example, constructed as a dict or a list of pairs. + Note that aliases are not shown. + + .. versionadded:: 2.0 + + Attributes + ----------- + value: :class:`int` + The raw value. This value is a bit array field of a 53-bit integer + representing the currently available flags. You should query + flags via the properties rather than using this raw value. + """ + + __slots__ = () + + @flag_value + def premium_purchase(self): + """:class:`bool`: Returns ``True`` if the SKU is a premium purchase.""" + return 1 << 0 + + @flag_value + def free_premium_content(self): + """:class:`bool`: Returns ``True`` if the SKU is free premium content.""" + return 1 << 1 + + @flag_value + def available(self): + """:class:`bool`: Returns ``True`` if the SKU is available for purchase.""" + return 1 << 2 + + @flag_value + def premium_and_distribution(self): + """:class:`bool`: Returns ``True`` if the SKU is a premium or distribution product.""" + return 1 << 3 + + @flag_value + def sticker_pack(self): + """:class:`bool`: Returns ``True`` if the SKU is a premium sticker pack.""" + return 1 << 4 + + @flag_value + def guild_role_subscription(self): + """:class:`bool`: Returns ``True`` if the SKU is a guild role subscription. These are subscriptions made to guilds for premium perks.""" + return 1 << 5 + + @flag_value + def premium_subscription(self): + """:class:`bool`: Returns ``True`` if the SKU is a Discord premium subscription or related first-party product. + These are subscriptions like Nitro and Server Boosts. These are the only giftable subscriptions. + """ + return 1 << 6 + + @flag_value + def application_subscription(self): + """:class:`bool`: Returns ``True`` if the SKU is a application subscription. These are subscriptions made to applications for premium perks.""" + return 1 << 7 + + +@fill_with_flags() +class PaymentFlags(BaseFlags): + r"""Wraps up the Discord payment flags. + + .. container:: operations + + .. describe:: x == y + + Checks if two PaymentFlags are equal. + .. describe:: x != y + + Checks if two PaymentFlags are not equal. + .. describe:: hash(x) + + Return the flag's hash. + .. describe:: iter(x) + + Returns an iterator of ``(name, value)`` pairs. This allows it + to be, for example, constructed as a dict or a list of pairs. + Note that aliases are not shown. + + .. versionadded:: 2.0 + + Attributes + ----------- + value: :class:`int` + The raw value. This value is a bit array field of a 53-bit integer + representing the currently available flags. You should query + flags via the properties rather than using this raw value. + """ + + __slots__ = () + + @flag_value + def gift(self): + """:class:`bool`: Returns ``True`` if the payment is for a gift.""" + return 1 << 0 + + @flag_value + def preorder(self): + """:class:`bool`: Returns ``True`` if the payment is a preorder.""" + return 1 << 3 + + # TODO: The below are assumptions + + @flag_value + def temporary_authorization(self): + """:class:`bool`: Returns ``True`` if the payment is a temporary authorization.""" + return 1 << 5 + + @flag_value + def unknown(self): + return 1 << 6 + + +@fill_with_flags() +class PromotionFlags(BaseFlags): + r"""Wraps up the Discord promotion flags. + + .. container:: operations + + .. describe:: x == y + + Checks if two PromotionFlags are equal. + .. describe:: x != y + + Checks if two PromotionFlags are not equal. + .. describe:: hash(x) + + Return the flag's hash. + .. describe:: iter(x) + + Returns an iterator of ``(name, value)`` pairs. This allows it + to be, for example, constructed as a dict or a list of pairs. + Note that aliases are not shown. + + .. versionadded:: 2.0 + + Attributes + ----------- + value: :class:`int` + The raw value. This value is a bit array field of a 53-bit integer + representing the currently available flags. You should query + flags via the properties rather than using this raw value. + """ + + __slots__ = () + + @flag_value + def unknown_0(self): + return 1 << 0 + + @flag_value + def unknown_1(self): + # Possibly one month duration? + return 1 << 1 + + @flag_value + def unknown_2(self): + return 1 << 2 + + @flag_value + def unknown_3(self): + return 1 << 3 + + @flag_value + def unknown_4(self): + # Possibly unavailable/ended/inactive + # Maybe also direct link + # Maybe also available for existing users + return 1 << 4 + + @flag_value + def blocked_ios(self): + """:class:`bool`: Returns ``True`` if the promotion is blocked on iOS.""" + return 1 << 5 + + @flag_value + def outbound_redeemable_by_trial_users(self): + """:class:`bool`: Returns ``True`` if the promotion is redeemable by trial users.""" + return 1 << 6 + + +@fill_with_flags() +class GiftFlags(BaseFlags): + r"""Wraps up the Discord payment flags. + + .. container:: operations + + .. describe:: x == y + + Checks if two PaymentFlags are equal. + .. describe:: x != y + + Checks if two PaymentFlags are not equal. + .. describe:: hash(x) + + Return the flag's hash. + .. describe:: iter(x) + + Returns an iterator of ``(name, value)`` pairs. This allows it + to be, for example, constructed as a dict or a list of pairs. + Note that aliases are not shown. + + .. versionadded:: 2.0 + + Attributes + ----------- + value: :class:`int` + The raw value. This value is a bit array field of a 53-bit integer + representing the currently available flags. You should query + flags via the properties rather than using this raw value. + """ + + __slots__ = () + + @flag_value + def payment_source_required(self): + """:class:`bool`: Returns ``True`` if the gift requires a payment source to redeem.""" + return 1 << 0 + + @flag_value + def existing_subscription_disallowed(self): + """:class:`bool`: Returns ``True`` if the gift cannot be redeemed by users with existing premium subscriptions.""" + return 1 << 1 + + @flag_value + def not_self_redeemable(self): + """:class:`bool`: Returns ``True`` if the gift cannot be redeemed by the gifter.""" + return 1 << 2 + + # TODO: The below are assumptions + + @flag_value + def promotion(self): + """:class:`bool`: Returns ``True`` if the gift is from a promotion.""" + return 1 << 3 + + +@fill_with_flags() +class LibraryApplicationFlags(BaseFlags): + r"""Wraps up the Discord library application flags. + + .. container:: operations + + .. describe:: x == y + + Checks if two LibraryApplicationFlags are equal. + .. describe:: x != y + + Checks if two LibraryApplicationFlags are not equal. + .. describe:: hash(x) + + Return the flag's hash. + .. describe:: iter(x) + + Returns an iterator of ``(name, value)`` pairs. This allows it + to be, for example, constructed as a dict or a list of pairs. + Note that aliases are not shown. + + .. versionadded:: 2.0 + + Attributes + ----------- + value: :class:`int` + The raw value. This value is a bit array field of a 53-bit integer + representing the currently available flags. You should query + flags via the properties rather than using this raw value. + """ + + __slots__ = () + + @flag_value + def hidden(self): + """:class:`bool`: Returns ``True`` if the library application is hidden.""" + return 1 << 0 + + @flag_value + def private(self): + """:class:`bool`: Returns ``True`` if the library application is not shown in playing status.""" + return 1 << 1 + + @flag_value + def overlay_disabled(self): + """:class:`bool`: Returns ``True`` if the library application has the Discord overlay disabled.""" + return 1 << 2 + + @flag_value + def entitled(self): + """:class:`bool`: Returns ``True`` if the library application is entitled to the user.""" + return 1 << 3 + + @flag_value + def premium(self): + """:class:`bool`: Returns ``True`` if the library application is free for premium users.""" + return 1 << 4 + + +@fill_with_flags() +class ApplicationDiscoveryFlags(BaseFlags): + r"""Wraps up the Discord application discovery eligibility flags. + + .. container:: operations + + .. describe:: x == y + + Checks if two LibraryApplicationFlags are equal. + .. describe:: x != y + + Checks if two LibraryApplicationFlags are not equal. + .. describe:: hash(x) + + Return the flag's hash. + .. describe:: iter(x) + + Returns an iterator of ``(name, value)`` pairs. This allows it + to be, for example, constructed as a dict or a list of pairs. + Note that aliases are not shown. + + .. versionadded:: 2.0 + + Attributes + ----------- + value: :class:`int` + The raw value. This value is a bit array field of a 53-bit integer + representing the currently available flags. You should query + flags via the properties rather than using this raw value. + """ + + __slots__ = () + + @flag_value + def verified(self): + """:class:`bool`: Returns ``True`` if the application is verified.""" + return 1 << 0 + + @flag_value + def tag(self): + """:class:`bool`: Returns ``True`` if the application has at least one tag set.""" + return 1 << 1 + + @flag_value + def description(self): + """:class:`bool`: Returns ``True`` if the application has a description.""" + return 1 << 2 + + @flag_value + def terms_of_service(self): + """:class:`bool`: Returns ``True`` if the application has a terms of service.""" + return 1 << 3 + + @flag_value + def privacy_policy(self): + """:class:`bool`: Returns ``True`` if the application has a privacy policy.""" + return 1 << 4 + + @flag_value + def install_params(self): + """:class:`bool`: Returns ``True`` if the application has a custom install URL or install parameters.""" + return 1 << 5 + + @flag_value + def safe_name(self): + """:class:`bool`: Returns ``True`` if the application name is safe for work.""" + return 1 << 6 + + @flag_value + def safe_description(self): + """:class:`bool`: Returns ``True`` if the application description is safe for work.""" + return 1 << 7 + + @flag_value + def approved_commands(self): + """:class:`bool`: Returns ``True`` if the application has the message content intent approved or utilizes application commands.""" + return 1 << 8 + + @flag_value + def support_guild(self): + """:class:`bool`: Returns ``True`` if the application has a support guild set.""" + return 1 << 9 + + @flag_value + def safe_commands(self): + """:class:`bool`: Returns ``True`` if the application's commands are safe for work.""" + return 1 << 10 + + @flag_value + def mfa(self): + """:class:`bool`: Returns ``True`` if the application's owner has MFA enabled.""" + return 1 << 11 + + @flag_value + def safe_directory_overview(self): + """:class:`bool`: Returns ``True`` if the application's directory long description is safe for work.""" + return 1 << 12 + + @flag_value + def supported_locales(self): + """:class:`bool`: Returns ``True`` if the application has at least one supported locale set.""" + return 1 << 13 + + @flag_value + def safe_short_description(self): + """:class:`bool`: Returns ``True`` if the application's directory short description is safe for work.""" + return 1 << 14 + + @flag_value + def safe_role_connections(self): + """:class:`bool`: Returns ``True`` if the application's role connections metadata is safe for work.""" + return 1 << 15 + + @flag_value + def eligible(self): + """:class:`bool`: Returns ``True`` if the application has met all the above criteria and is eligible for discovery.""" + return 1 << 16 diff --git a/discord/guild.py b/discord/guild.py index 17163f2a9..792913670 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -89,6 +89,9 @@ from .object import OLDEST_OBJECT, Object from .profile import MemberProfile from .partial_emoji import PartialEmoji from .welcome_screen import * +from .appinfo import PartialApplication +from .guild_premium import PremiumGuildSubscription +from .entitlements import Entitlement # fmt: off @@ -105,7 +108,7 @@ if TYPE_CHECKING: from .abc import Snowflake, SnowflakeTime from .types.guild import ( Guild as GuildPayload, - GuildPreview as GuildPreviewPayload, + PartialGuild as PartialGuildPayload, RolePositionUpdate as RolePositionUpdatePayload, ) from .types.threads import ( @@ -119,6 +122,7 @@ if TYPE_CHECKING: from .state import ConnectionState from .voice_client import VoiceProtocol from .settings import GuildSettings + from .enums import ApplicationType from .types.channel import ( GuildChannel as GuildChannelPayload, TextChannel as TextChannelPayload, @@ -335,7 +339,7 @@ class Guild(Hashable): 3: _GuildLimit(emoji=250, stickers=60, bitrate=384e3, filesize=104857600), } - def __init__(self, *, data: Union[GuildPayload, GuildPreviewPayload], state: ConnectionState) -> None: + def __init__(self, *, data: Union[GuildPayload, PartialGuildPayload], state: ConnectionState) -> None: self._chunked = False self._cs_joined: Optional[bool] = None self._roles: Dict[int, Role] = {} @@ -446,7 +450,7 @@ class Guild(Hashable): return role - def _from_data(self, guild: Union[GuildPayload, GuildPreviewPayload]) -> None: + def _from_data(self, guild: Union[GuildPayload, PartialGuildPayload]) -> None: try: self._member_count: int = guild['member_count'] # type: ignore # Handled below except KeyError: @@ -874,7 +878,7 @@ class Guild(Hashable): @property def premium_subscribers(self) -> List[Member]: - """List[:class:`Member`]: A list of members who have "boosted" this guild.""" + """List[:class:`Member`]: A list of members who have subscribed to (boosted) this guild.""" return [member for member in self.members if member.premium_since is not None] @property @@ -2346,10 +2350,10 @@ class Guild(Hashable): return Template(state=self._state, data=data) - async def create_integration(self, *, type: IntegrationType, id: int) -> None: + async def create_integration(self, *, type: IntegrationType, id: int, reason: Optional[str] = None) -> None: """|coro| - Attaches an integration to the guild. + Attaches an integration to the guild. This "enables" an existing integration. You must have the :attr:`~Permissions.manage_guild` permission to do this. @@ -2362,6 +2366,10 @@ class Guild(Hashable): The integration type (e.g. Twitch). id: :class:`int` The integration ID. + reason: Optional[:class:`str`] + The reason for creating this integration. Shows up on the audit log. + + .. versionadded:: 2.0 Raises ------- @@ -2370,7 +2378,7 @@ class Guild(Hashable): HTTPException The account could not be found. """ - await self._state.http.create_integration(self.id, type, id) + await self._state.http.create_integration(self.id, type, id, reason=reason) async def integrations(self, *, with_applications=True) -> List[Integration]: """|coro| @@ -3557,6 +3565,161 @@ class Guild(Hashable): if payload: await self._state.http.edit_welcome_screen(self.id, payload) + async def applications( + self, *, with_team: bool = False, type: Optional[ApplicationType] = None, channel: Optional[Snowflake] = None + ) -> List[PartialApplication]: + """|coro| + + Returns the list of applications that are attached to this guild. + + .. versionadded:: 2.0 + + Parameters + ----------- + with_team: :class:`bool` + Whether to include the team of the application. + type: :class:`ApplicationType` + The type of application to restrict the returned applications to. + + Raises + ------- + HTTPException + Fetching the applications failed. + + Returns + -------- + List[:class:`PartialApplication`] + The applications that belong to this guild. + """ + data = await self._state.http.get_guild_applications( + self.id, include_team=with_team, type=int(type) if type else None, channel_id=channel.id if channel else None + ) + return [PartialApplication(state=self._state, data=app) for app in data] + + async def premium_subscriptions(self) -> List[PremiumGuildSubscription]: + """|coro| + + Returns the list of premium subscriptions (boosts) for this guild. + + .. versionadded:: 2.0 + + Raises + ------- + Forbidden + You do not have permission to get the premium guild subscriptions. + HTTPException + Fetching the premium guild subscriptions failed. + + Returns + -------- + List[:class:`PremiumGuildSubscription`] + The premium guild subscriptions. + """ + data = await self._state.http.get_guild_subscriptions(self.id) + return [PremiumGuildSubscription(state=self._state, data=sub) for sub in data] + + async def apply_premium_subscription_slots(self, *subscription_slots: Snowflake) -> List[PremiumGuildSubscription]: + r"""|coro| + + Applies premium subscription slots to the guild (boosts the guild). + + .. versionadded:: 2.0 + + Parameters + ----------- + \*subscription_slots: :class:`PremiumGuildSubscriptionSlot` + The subscription slots to apply. + + Raises + ------- + HTTPException + Applying the premium subscription slots failed. + """ + if not subscription_slots: + return [] + + data = await self._state.http.apply_guild_subscription_slots(self.id, [slot.id for slot in subscription_slots]) + return [PremiumGuildSubscription(state=self._state, data=sub) for sub in data] + + async def entitlements( + self, *, with_sku: bool = True, with_application: bool = True, exclude_deleted: bool = False + ) -> List[Entitlement]: + """|coro| + + Returns the list of entitlements for this guild. + + .. versionadded:: 2.0 + + Parameters + ----------- + with_sku: :class:`bool` + Whether to include the SKU information in the returned entitlements. + with_application: :class:`bool` + Whether to include the application in the returned entitlements' SKUs. + exclude_deleted: :class:`bool` + Whether to exclude deleted entitlements. + + Raises + ------- + HTTPException + Retrieving the entitlements failed. + + Returns + ------- + List[:class:`Entitlement`] + The guild's entitlements. + """ + state = self._state + data = await state.http.get_guild_entitlements( + self.id, with_sku=with_sku, with_application=with_application, exclude_deleted=exclude_deleted + ) + return [Entitlement(state=state, data=d) for d in data] + + async def price_tiers(self) -> List[int]: + """|coro| + + Returns the list of price tiers available for use in this guild. + + .. versionadded:: 2.0 + + Raises + ------- + HTTPException + Fetching the price tiers failed. + + Returns + -------- + List[:class:`int`] + The available price tiers. + """ + return await self._state.http.get_price_tiers(1, self.id) + + async def fetch_price_tier(self, price_tier: int, /) -> Dict[str, int]: + """|coro| + + Returns a mapping of currency to price for the given price tier. + + .. versionadded:: 2.0 + + Parameters + ----------- + price_tier: :class:`int` + The price tier to retrieve. + + Raises + ------- + NotFound + The price tier does not exist. + HTTPException + Fetching the price tier failed. + + Returns + ------- + Dict[:class:`str`, :class:`int`] + The price tier mapping. + """ + return await self._state.http.get_price_tier(price_tier) + async def chunk(self, channel: Snowflake = MISSING) -> List[Member]: """|coro| diff --git a/discord/guild_premium.py b/discord/guild_premium.py new file mode 100644 index 000000000..35d03d37c --- /dev/null +++ b/discord/guild_premium.py @@ -0,0 +1,320 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Dolfies + +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 datetime import datetime, timedelta +from typing import TYPE_CHECKING, Optional + +from .mixins import Hashable +from .subscriptions import Subscription +from .utils import parse_time, utcnow + +if TYPE_CHECKING: + from .abc import Snowflake + from .guild import Guild + from .state import ConnectionState + from .types.subscriptions import ( + PremiumGuildSubscription as PremiumGuildSubscriptionPayload, + PremiumGuildSubscriptionSlot as PremiumGuildSubscriptionSlotPayload, + PremiumGuildSubscriptionCooldown as PremiumGuildSubscriptionCooldownPayload, + ) + +__all__ = ( + 'PremiumGuildSubscription', + 'PremiumGuildSubscriptionSlot', + 'PremiumGuildSubscriptionCooldown', +) + + +class PremiumGuildSubscription(Hashable): + """Represents a premium guild subscription (boost). + + .. container:: operations + + .. describe:: x == y + + Checks if two premium guild subscriptions are equal. + + .. describe:: x != y + + Checks if two premium guild subscriptions are not equal. + + .. describe:: hash(x) + + Returns the premium guild subscription's hash. + + .. versionadded:: 2.0 + + Attributes + ------------ + id: :class:`int` + The ID of the guild premium subscription. + guild_id: :class:`int` + The ID of the guild this guild premium subscription belongs to. + user_id: :class:`int` + The ID of the user this guild premium subscription belongs to. + user: :class:`User` + The user this guild premium subscription belongs to. + ended: :class:`bool` + Whether the guild premium subscription has ended. + ends_at: Optional[:class:`datetime.datetime`] + When the guild premium subscription ends. + """ + + def __init__(self, *, state: ConnectionState, data: PremiumGuildSubscriptionPayload): + self._state = state + self._update(data) + + def _update(self, data: PremiumGuildSubscriptionPayload): + state = self._state + + self.id = int(data['id']) + self.guild_id = int(data['guild_id']) + self.user_id = int(data['user_id']) + self.user = state.store_user(data['user']) if 'user' in data else state.user + self.ended = data.get('ended', False) + self.ends_at: Optional[datetime] = parse_time(data.get('ends_at')) + + def __repr__(self) -> str: + return f'' + + @property + def guild(self) -> Optional[Guild]: + """Optional[:class:`Guild`]: The guild this guild premium subscription belongs to, if available.""" + return self._state._get_guild(self.guild_id) + + @property + def remaining(self) -> Optional[timedelta]: + """Optional[:class:`datetime.timedelta`]: The remaining time for this guild premium subscription. + + This is ``None`` if the subscription is not ending. + """ + if self.ends_at is None or self.ends_at <= utcnow(): + return None + + return self.ends_at - utcnow() + + async def delete(self) -> None: + """|coro| + + Deletes this guild premium subscription. + + Raises + ------- + Forbidden + You do not have permissions to delete this guild premium subscription. + HTTPException + Deleting the guild premium subscription failed. + """ + await self._state.http.delete_guild_subscription(self.guild_id, self.id) + + +class PremiumGuildSubscriptionSlot(Hashable): + """Represents a premium guild subscription (boost) slot. + + This is a slot that can be used on a guild (to boost it). + + .. container:: operations + + .. describe:: x == y + + Checks if two subscription slots are equal. + + .. describe:: x != y + + Checks if two subscription slots are not equal. + + .. describe:: hash(x) + + Returns the subscription slot's hash. + + .. versionadded:: 2.0 + + Attributes + ------------ + id: :class:`int` + The ID of the guild subscription slot. + subscription_id: :class:`int` + The ID of the guild subscription this slot belongs to. + canceled: :class:`bool` + Whether the slot is canceled. + cooldown_ends_at: Optional[:class:`datetime.datetime`] + When the cooldown for this guild subscription slot ends. + premium_guild_subscription: Optional[:class:`PremiumGuildSubscription`] + The subscription this slot belongs to. + """ + + __slots__ = ( + 'id', + 'subscription_id', + 'canceled', + 'cooldown_ends_at', + 'premium_guild_subscription', + '_state', + ) + + def __init__(self, *, state: ConnectionState, data: PremiumGuildSubscriptionSlotPayload): + self._state = state + self._update(data) + + def _update(self, data: PremiumGuildSubscriptionSlotPayload): + self.id = int(data['id']) + self.subscription_id = int(data['subscription_id']) + self.canceled = data.get('canceled', False) + self.cooldown_ends_at: Optional[datetime] = parse_time(data.get('cooldown_ends_at')) + + premium_guild_subscription = data.get('premium_guild_subscription') + self.premium_guild_subscription: Optional[PremiumGuildSubscription] = ( + PremiumGuildSubscription(state=self._state, data=premium_guild_subscription) + if premium_guild_subscription is not None + else None + ) + + def __repr__(self) -> str: + return f'' + + def is_available(self) -> bool: + """:class:`bool`: Indicates if the slot is available for use.""" + return not self.premium_guild_subscription and not self.is_on_cooldown() + + def is_on_cooldown(self) -> bool: + """:class:`bool`: Indicates if the slot is on cooldown.""" + return self.cooldown_ends_at is not None and self.cooldown_ends_at > utcnow() + + @property + def cancelled(self) -> bool: + """:class:`bool`: Whether the slot is cancelled. + + This is an alias of :attr:`canceled`. + """ + return self.canceled + + @property + def cooldown_remaining(self) -> Optional[timedelta]: + """Optional[:class:`datetime.timedelta`]: The cooldown remaining for this boost slot. + + This is ``None`` if the cooldown has ended. + """ + if self.cooldown_ends_at is None or self.cooldown_ends_at <= utcnow(): + return None + + return self.cooldown_ends_at - utcnow() + + async def subscription(self) -> Subscription: + """|coro| + + Retrieves the subscription this guild subscription slot is attached to. + + Raises + ------ + NotFound + You cannot access this subscription. + HTTPException + Fetching the subscription failed. + + Returns + ------- + :class:`Subscription` + The retrieved subscription, if applicable. + """ + data = await self._state.http.get_subscription(self.subscription_id) + return Subscription(data=data, state=self._state) + + async def apply(self, guild: Snowflake) -> PremiumGuildSubscription: + """|coro| + + Applies the premium guild subscription slot to a guild. + + Parameters + ----------- + guild: :class:`Guild` + The guild to apply the slot to. + + Raises + ------- + HTTPException + Applying the slot failed. + + Returns + -------- + :class:`PremiumGuildSubscription` + The premium guild subscription that was created. + """ + state = self._state + data = await state.http.apply_guild_subscription_slots(guild.id, (self.id,)) + return PremiumGuildSubscription(state=state, data=data[0]) + + async def cancel(self) -> None: + """|coro| + + Cancels the guild subscription slot. + + Raises + ------- + HTTPException + Cancelling the slot failed. + """ + data = await self._state.http.cancel_guild_subscription_slot(self.id) + self._update(data) + + async def uncancel(self) -> None: + """|coro| + + Uncancels the guild subscription slot. + + Raises + ------- + HTTPException + Uncancelling the slot failed. + """ + data = await self._state.http.uncancel_guild_subscription_slot(self.id) + self._update(data) + + +class PremiumGuildSubscriptionCooldown: + """Represents a premium guild subscription cooldown. + + This is a cooldown that is applied to your guild subscription slot changes (boosting and unboosting). + + .. versionadded:: 2.0 + + Attributes + ------------ + ends_at: :class:`datetime.datetime` + When the cooldown resets. + limit: :class:`int` + The maximum number of changes that can be made before the cooldown is applied. + remaining: :class:`int` + The number of changes remaining before the cooldown is applied. + """ + + def __init__(self, *, state: ConnectionState, data: PremiumGuildSubscriptionCooldownPayload): + self._state = state + self._update(data) + + def _update(self, data: PremiumGuildSubscriptionCooldownPayload): + self.ends_at: datetime = parse_time(data['ends_at']) + self.limit = data['limit'] + self.remaining = data.get('remaining', 0) diff --git a/discord/http.py b/discord/http.py index f151a05ce..3f5de1e7e 100644 --- a/discord/http.py +++ b/discord/http.py @@ -28,7 +28,8 @@ import asyncio from base64 import b64encode import json import logging -from random import choice +from random import choice, choices +import string from typing import ( Any, ClassVar, @@ -37,6 +38,7 @@ from typing import ( Iterable, List, Literal, + Mapping, NamedTuple, Optional, overload, @@ -85,13 +87,18 @@ if TYPE_CHECKING: from .types import ( appinfo, audit_log, + billing, channel, emoji, + entitlements, guild, integration, invite, + library, member, message, + payments, + promotions, template, role, user, @@ -100,6 +107,8 @@ if TYPE_CHECKING: team, threads, scheduled_event, + store, + subscriptions, sticker, welcome_screen, ) @@ -531,7 +540,7 @@ class HTTPClient: # Request was successful so just return the text/json if 300 > response.status >= 200: - _log.debug('%s %s has received %s', method, url, data) + _log.debug('%s %s has received %s.', method, url, data) return data # Rate limited @@ -624,6 +633,58 @@ class HTTPClient: else: raise HTTPException(resp, 'failed to get asset') + async def upload_to_cloud(self, url: str, file: Union[File, str], hash: Optional[str] = None) -> Any: + response: Optional[aiohttp.ClientResponse] = None + data: Optional[Union[Dict[str, Any], str]] = None + + # aiohttp helpfully sets the content type for us, + # but Google explodes if we do that; therefore, empty string + headers = {'Content-Type': ''} + if hash: + headers['Content-MD5'] = hash + + for tries in range(5): + if isinstance(file, File): + file.reset(seek=tries) + + try: + async with self.__session.put(url, data=getattr(file, 'fp', file), headers=headers) as response: + _log.debug('PUT %s with %s has returned %s.', url, file, response.status) + data = await json_or_text(response) + + # Request was successful so just return the text/json + if 300 > response.status >= 200: + _log.debug('PUT %s has received %s.', url, data) + return data + + # Unconditional retry + if response.status in {500, 502, 504}: + await asyncio.sleep(1 + tries * 2) + continue + + # Usual error cases + if response.status == 403: + raise Forbidden(response, data) + elif response.status == 404: + raise NotFound(response, data) + elif response.status >= 500: + raise DiscordServerError(response, data) + else: + raise HTTPException(response, data) + except OSError as e: + # Connection reset by peer + if tries < 4 and e.errno in (54, 10054): + await asyncio.sleep(1 + tries * 2) + continue + raise + + if response is not None: + # We've run out of retries, raise + if response.status >= 500: + raise DiscordServerError(response, data) + + raise HTTPException(response, data) + # State management def recreate(self) -> None: @@ -679,13 +740,19 @@ class HTTPClient: return self.request(Route('POST', '/users/@me/channels'), json=payload, context_properties=props) - def add_group_recipient(self, channel_id: Snowflake, user_id: Snowflake): # TODO: return typings - r = Route('PUT', '/channels/{channel_id}/recipients/{user_id}', channel_id=channel_id, user_id=user_id) - return self.request(r) + def add_group_recipient(self, channel_id: Snowflake, user_id: Snowflake, nick: Optional[str] = None) -> Response[None]: + payload = None + if nick: + payload = {'nick': nick} - def remove_group_recipient(self, channel_id: Snowflake, user_id: Snowflake): # TODO: return typings - r = Route('DELETE', '/channels/{channel_id}/recipients/{user_id}', channel_id=channel_id, user_id=user_id) - return self.request(r) + return self.request( + Route('PUT', '/channels/{channel_id}/recipients/{user_id}', channel_id=channel_id, user_id=user_id), json=payload + ) + + def remove_group_recipient(self, channel_id: Snowflake, user_id: Snowflake) -> Response[None]: + return self.request( + Route('DELETE', '/channels/{channel_id}/recipients/{user_id}', channel_id=channel_id, user_id=user_id) + ) def get_private_channels(self) -> Response[List[Union[channel.DMChannel, channel.GroupDMChannel]]]: return self.request(Route('GET', '/users/@me/channels')) @@ -1460,13 +1527,13 @@ class HTTPClient: return self.request(Route('GET', '/stickers/{sticker_id}/guild', sticker_id=sticker_id)) def list_premium_sticker_packs( - self, country: str = 'US', locale: str = 'en-US', payment_source_id: Snowflake = MISSING + self, country: str = 'US', locale: str = 'en-US', payment_source_id: Optional[Snowflake] = None ) -> Response[sticker.ListPremiumStickerPacks]: params: Dict[str, Snowflake] = { 'country_code': country, 'locale': locale, } - if payment_source_id is not MISSING: + if payment_source_id: params['payment_source_id'] = payment_source_id return self.request(Route('GET', '/sticker-packs'), params=params) @@ -1611,26 +1678,33 @@ class HTTPClient: return self.request(r, params=params) - def create_integration(self, guild_id: Snowflake, type: integration.IntegrationType, id: int) -> Response[None]: - r = Route('POST', '/guilds/{guild_id}/integrations', guild_id=guild_id) + def create_integration( + self, guild_id: Snowflake, type: integration.IntegrationType, id: int, *, reason: Optional[str] = None + ) -> Response[None]: payload = { 'type': type, 'id': id, } - return self.request(r, json=payload) + return self.request(Route('POST', '/guilds/{guild_id}/integrations', guild_id=guild_id), json=payload, reason=reason) def edit_integration(self, guild_id: Snowflake, integration_id: Snowflake, **payload: Any) -> Response[None]: - r = Route( - 'PATCH', '/guilds/{guild_id}/integrations/{integration_id}', guild_id=guild_id, integration_id=integration_id + return self.request( + Route( + 'PATCH', '/guilds/{guild_id}/integrations/{integration_id}', guild_id=guild_id, integration_id=integration_id + ), + json=payload, ) - return self.request(r, json=payload) def sync_integration(self, guild_id: Snowflake, integration_id: Snowflake) -> Response[None]: - r = Route( - 'POST', '/guilds/{guild_id}/integrations/{integration_id}/sync', guild_id=guild_id, integration_id=integration_id + return self.request( + Route( + 'POST', + '/guilds/{guild_id}/integrations/{integration_id}/sync', + guild_id=guild_id, + integration_id=integration_id, + ) ) - return self.request(r) def delete_integration( self, guild_id: Snowflake, integration_id: Snowflake, *, reason: Optional[str] = None @@ -1698,14 +1772,10 @@ class HTTPClient: elif type is InviteType.guild or type is InviteType.group_dm: # Join Guild, Accept Invite Page props = choice( ( - ContextProperties._from_accept_invite_page( - guild_id=guild_id, channel_id=channel_id, channel_type=channel_type - ), - ContextProperties._from_join_guild_popup( - guild_id=guild_id, channel_id=channel_id, channel_type=channel_type - ), + ContextProperties._from_accept_invite_page, + ContextProperties._from_join_guild_popup, ) - ) + )(guild_id=guild_id, channel_id=channel_id, channel_type=channel_type) else: # Accept Invite Page props = ContextProperties._from_accept_invite_page( guild_id=guild_id, channel_id=channel_id, channel_type=channel_type @@ -1725,7 +1795,6 @@ class HTTPClient: target_user_id: Optional[Snowflake] = None, target_application_id: Optional[Snowflake] = None, ) -> Response[invite.Invite]: - r = Route('POST', '/channels/{channel_id}/invites', channel_id=channel_id) payload = { 'max_age': max_age, 'max_uses': max_uses, @@ -1738,8 +1807,19 @@ class HTTPClient: payload['target_user_id'] = target_user_id if target_application_id: payload['target_application_id'] = str(target_application_id) + props = choice( + ( + ContextProperties._from_guild_header, + ContextProperties._from_context_menu, + ) + )() - return self.request(r, reason=reason, json=payload) + return self.request( + Route('POST', '/channels/{channel_id}/invites', channel_id=channel_id), + reason=reason, + json=payload, + context_properties=props, + ) def create_group_invite(self, channel_id: Snowflake, *, max_age: int = 86400) -> Response[invite.Invite]: payload = { @@ -1858,7 +1938,7 @@ class HTTPClient: ) def add_members_to_role( - self, guild_id: Snowflake, role_id: Snowflake, member_ids: List[Snowflake], *, reason: Optional[str] + self, guild_id: Snowflake, role_id: Snowflake, member_ids: Sequence[Snowflake], *, reason: Optional[str] ) -> Response[Dict[Snowflake, member.MemberWithUser]]: payload = {'member_ids': member_ids} @@ -2146,30 +2226,29 @@ class HTTPClient: if action is RelationshipAction.deny_request: # User Profile, Friends, DM Channel props = choice( ( - ContextProperties._from_friends_page(), - ContextProperties._from_user_profile(), - ContextProperties._from_dm_channel(), + ContextProperties._from_friends_page, + ContextProperties._from_user_profile, + ContextProperties._from_dm_channel, ) - ) + )() elif action is RelationshipAction.unfriend: # Friends, ContextMenu, User Profile, DM Channel props = choice( ( - ContextProperties._from_context_menu(), - ContextProperties._from_user_profile(), - ContextProperties._from_friends_page(), - ContextProperties._from_dm_channel(), + ContextProperties._from_contextmenu, + ContextProperties._from_user_profile, + ContextProperties._from_friends_page, + ContextProperties._from_dm_channel, ) - ) + )() elif action == RelationshipAction.unblock: # Friends, ContextMenu, User Profile, DM Channel, NONE props = choice( ( - ContextProperties._from_context_menu(), - ContextProperties._from_user_profile(), - ContextProperties._from_friends_page(), - ContextProperties._from_dm_channel(), - None, + ContextProperties._from_contextmenu, + ContextProperties._from_user_profile, + ContextProperties._from_friends_page, + ContextProperties._from_dm_channel, ) - ) + )() elif action == RelationshipAction.remove_pending_request: # Friends props = ContextProperties._from_friends_page() @@ -2180,42 +2259,42 @@ class HTTPClient: if action is RelationshipAction.accept_request: # User Profile, Friends, DM Channel props = choice( ( - ContextProperties._from_friends_page(), - ContextProperties._from_user_profile(), - ContextProperties._from_dm_channel(), + ContextProperties._from_friends_page, + ContextProperties._from_user_profile, + ContextProperties._from_dm_channel, ) - ) + )() elif action is RelationshipAction.block: # Friends, ContextMenu, User Profile, DM Channel. props = choice( ( - ContextProperties._from_context_menu(), - ContextProperties._from_user_profile(), - ContextProperties._from_friends_page(), - ContextProperties._from_dm_channel(), + ContextProperties._from_contextmenu, + ContextProperties._from_user_profile, + ContextProperties._from_friends_page, + ContextProperties._from_dm_channel, ) - ) + )() elif action is RelationshipAction.send_friend_request: # ContextMenu, User Profile, DM Channel props = choice( ( - ContextProperties._from_context_menu(), - ContextProperties._from_user_profile(), - ContextProperties._from_dm_channel(), + ContextProperties._from_contextmenu, + ContextProperties._from_user_profile, + ContextProperties._from_dm_channel, ) - ) + )() kwargs = {'context_properties': props} # type: ignore if type: kwargs['json'] = {'type': type} return self.request(r, **kwargs) - def send_friend_request(self, username, discriminator) -> Response[None]: + def send_friend_request(self, username: str, discriminator: Snowflake) -> Response[None]: r = Route('POST', '/users/@me/relationships') - props = choice((ContextProperties._from_add_friend_page, ContextProperties._from_group_dm)) # Friends, Group DM + props = choice((ContextProperties._from_add_friend_page, ContextProperties._from_group_dm))() # Friends, Group DM payload = {'username': username, 'discriminator': int(discriminator)} return self.request(r, json=payload, context_properties=props) - def edit_relationship(self, user_id, **payload): # TODO: return type + def edit_relationship(self, user_id: Snowflake, **payload) -> Response[None]: return self.request(Route('PATCH', '/users/@me/relationships/{user_id}', user_id=user_id), json=payload) # Connections @@ -2232,16 +2311,23 @@ class HTTPClient: def delete_connection(self, type: str, id: str) -> Response[None]: return self.request(Route('DELETE', '/users/@me/connections/{type}/{id}', type=type, id=id)) + def get_reddit_connection_subreddits(self, id: str) -> Response[List[dict]]: + return self.request(Route('GET', '/users/@me/connections/reddit/{id}/subreddits', id=id)) + def authorize_connection( self, type: str, two_way_link_type: Optional[str] = None, + two_way_user_code: Optional[str] = None, continuation: bool = False, ) -> Response[user.ConnectionAuthorization]: params = {} if two_way_link_type is not None: params['two_way_link'] = 'true' params['two_way_link_type'] = two_way_link_type + if two_way_user_code is not None: + params['two_way_link'] = 'true' + params['two_way_user_code'] = two_way_user_code if continuation: params['continuation'] = 'true' return self.request(Route('GET', '/connections/{type}/authorize', type=type), params=params) @@ -2249,24 +2335,33 @@ class HTTPClient: def add_connection( self, type: str, - **payload, + code: str, + state: str, + *, + two_way_link_code: Optional[str] = None, + insecure: bool, + friend_sync: bool, ) -> Response[None]: + payload = {'code': code, 'state': state, 'insecure': insecure, 'friend_sync': friend_sync} + if two_way_link_code is not None: + payload['two_way_link_code'] = two_way_link_code + return self.request(Route('POST', '/connections/{type}/callback', type=type), json=payload) def get_connection_token(self, type: str, id: str) -> Response[user.ConnectionAccessToken]: return self.request(Route('GET', '/users/@me/connections/{type}/{id}/access-token', type=type, id=id)) - # Applications + # Applications / Store - def get_my_applications(self, *, with_team_applications: bool = True) -> Response[List[appinfo.AppInfo]]: + def get_my_applications(self, *, with_team_applications: bool = True) -> Response[List[appinfo.Application]]: params = {'with_team_applications': str(with_team_applications).lower()} - return self.request(Route('GET', '/applications'), params=params, super_properties_to_track=True) + return self.request(Route('GET', '/applications'), params=params) - def get_my_application(self, app_id: Snowflake) -> Response[appinfo.AppInfo]: + def get_my_application(self, app_id: Snowflake) -> Response[appinfo.Application]: return self.request(Route('GET', '/applications/{app_id}', app_id=app_id), super_properties_to_track=True) - def edit_application(self, app_id: Snowflake, payload) -> Response[appinfo.AppInfo]: + def edit_application(self, app_id: Snowflake, payload: dict) -> Response[appinfo.Application]: return self.request( Route('PATCH', '/applications/{app_id}', app_id=app_id), super_properties_to_track=True, json=payload ) @@ -2274,64 +2369,281 @@ class HTTPClient: def delete_application(self, app_id: Snowflake) -> Response[None]: return self.request(Route('POST', '/applications/{app_id}/delete', app_id=app_id), super_properties_to_track=True) - def transfer_application(self, app_id: Snowflake, team_id: Snowflake) -> Response[appinfo.AppInfo]: + def transfer_application(self, app_id: Snowflake, team_id: Snowflake) -> Response[appinfo.Application]: payload = {'team_id': team_id} return self.request( Route('POST', '/applications/{app_id}/transfer', app_id=app_id), json=payload, super_properties_to_track=True ) - def get_partial_application(self, app_id: Snowflake) -> Response[appinfo.PartialAppInfo]: + def get_partial_application(self, app_id: Snowflake) -> Response[appinfo.PartialApplication]: return self.request(Route('GET', '/oauth2/applications/{app_id}/rpc', app_id=app_id)) - def get_public_application(self, app_id: Snowflake) -> Response[appinfo.PartialAppInfo]: - return self.request(Route('GET', '/applications/{app_id}/public', app_id=app_id)) + def get_public_application(self, app_id: Snowflake, with_guild: bool = False) -> Response[appinfo.PartialApplication]: + params = {'with_guild': str(with_guild).lower()} + return self.request(Route('GET', '/applications/{app_id}/public', app_id=app_id), params=params) - def get_public_applications(self, app_ids: Sequence[Snowflake]) -> Response[List[appinfo.PartialAppInfo]]: + def get_public_applications(self, app_ids: Sequence[Snowflake]) -> Response[List[appinfo.PartialApplication]]: return self.request(Route('GET', '/applications/public'), params={'application_ids': app_ids}) - def create_app(self, name: str): - payload = {'name': name} + def create_app(self, name: str, team_id: Optional[Snowflake] = None) -> Response[appinfo.Application]: + payload = {'name': name, team_id: team_id} + + return self.request(Route('POST', '/applications'), json=payload) + + def get_app_entitlements( + self, + app_id: Snowflake, + *, + user_id: Optional[Snowflake] = None, + guild_id: Optional[Snowflake] = None, + sku_ids: Optional[Sequence[Snowflake]] = None, + with_payments: bool = False, + exclude_ended: bool = False, + before: Optional[Snowflake] = None, + after: Optional[Snowflake] = None, + limit: int = 100, + ) -> Response[List[entitlements.Entitlement]]: + params: Dict[str, Any] = {'with_payments': str(with_payments).lower(), 'exclude_ended': str(exclude_ended).lower()} + if user_id: + params['user_id'] = user_id + if guild_id: + params['guild_id'] = guild_id + if sku_ids: + params['sku_ids'] = sku_ids + if before: + params['before'] = before + if after: + params['after'] = after + if limit != 100: + params['limit'] = limit + + return self.request(Route('GET', '/applications/{app_id}/entitlements', app_id=app_id), params=params) + + def get_app_entitlement( + self, app_id: Snowflake, entitlement_id: Snowflake, with_payments: bool = False + ) -> Response[entitlements.Entitlement]: + params = {'with_payments': str(with_payments).lower()} + return self.request( + Route( + 'GET', '/applications/{app_id}/entitlements/{entitlement_id}', app_id=app_id, entitlement_id=entitlement_id + ), + params=params, + ) + + def delete_app_entitlement(self, app_id: Snowflake, entitlement_id: Snowflake) -> Response[None]: + return self.request( + Route( + 'DELETE', + '/applications/{app_id}/entitlements/{entitlement_id}', + app_id=app_id, + entitlement_id=entitlement_id, + ) + ) + + def consume_app_entitlement(self, app_id: Snowflake, entitlement_id: Snowflake) -> Response[None]: + return self.request( + Route( + 'POST', + '/applications/{app_id}/entitlements/{entitlement_id}/consume', + app_id=app_id, + entitlement_id=entitlement_id, + ) + ) + + def get_user_app_entitlements( + self, app_id: Snowflake, *, sku_ids: Optional[Sequence[Snowflake]] = None, exclude_consumed: bool = True + ) -> Response[List[entitlements.Entitlement]]: + params: Dict[str, Any] = {'exclude_consumed': str(exclude_consumed).lower()} + if sku_ids: + params['sku_ids'] = sku_ids + return self.request(Route('GET', '/users/@me/applications/{app_id}/entitlements', app_id=app_id, params=params)) + + def get_user_entitlements( + self, with_sku: bool = True, with_application: bool = True, entitlement_type: Optional[int] = None + ) -> Response[List[entitlements.Entitlement]]: + params: Dict[str, Any] = {'with_sku': str(with_sku).lower(), 'with_application': str(with_application).lower()} + if entitlement_type is not None: + params['entitlement_type'] = entitlement_type + + return self.request(Route('GET', '/users/@me/entitlements'), params=params) + + def get_giftable_entitlements( + self, country_code: Optional[str] = None, payment_source_id: Optional[Snowflake] = None + ) -> Response[List[entitlements.Entitlement]]: + params = {} + if country_code: + params['country_code'] = country_code + if payment_source_id: + params['payment_source_id'] = payment_source_id - return self.request(Route('POST', '/applications'), json=payload, super_properties_to_track=True) + return self.request(Route('GET', '/users/@me/entitlements/gifts'), params=params) - def get_app_entitlements(self, app_id: Snowflake): # TODO: return type - r = Route('GET', '/users/@me/applications/{app_id}/entitlements', app_id=app_id) - return self.request(r, super_properties_to_track=True) + def get_guild_entitlements( + self, guild_id: Snowflake, with_sku: bool = True, with_application: bool = True, exclude_deleted: bool = False + ) -> Response[List[entitlements.Entitlement]]: + params: Dict[str, Any] = { + 'with_sku': str(with_sku).lower(), + 'with_application': str(with_application).lower(), + 'exclude_deleted': str(exclude_deleted).lower(), + } + return self.request(Route('GET', '/guilds/{guild_id}/entitlements', guild_id=guild_id), params=params) def get_app_skus( - self, app_id: Snowflake, *, localize: bool = False, with_bundled_skus: bool = True - ): # TODO: return type - r = Route('GET', '/applications/{app_id}/skus', app_id=app_id) - params = {'localize': str(localize).lower(), 'with_bundled_skus': str(with_bundled_skus).lower()} + self, + app_id: Snowflake, + *, + country_code: Optional[str] = None, + payment_source_id: Optional[Snowflake] = None, + localize: bool = True, + with_bundled_skus: bool = True, + ) -> Response[List[store.SKU]]: + params = {} + if country_code: + params['country_code'] = country_code + if payment_source_id: + params['payment_source_id'] = payment_source_id + if not localize: + params['localize'] = 'false' + if with_bundled_skus: + params['with_bundled_skus'] = 'true' + + return self.request( + Route('GET', '/applications/{app_id}/skus', app_id=app_id), params=params, super_properties_to_track=True + ) + + def create_sku(self, payload: dict) -> Response[store.SKU]: + return self.request(Route('POST', '/store/skus'), json=payload, super_properties_to_track=True) + + def get_app_discoverability(self, app_id: Snowflake) -> Response[appinfo.ApplicationDiscoverability]: + return self.request( + Route('GET', '/applications/{app_id}/discoverability-state', app_id=app_id), super_properties_to_track=True + ) + + def get_embedded_activity_config(self, app_id: Snowflake) -> Response[appinfo.EmbeddedActivityConfig]: + return self.request( + Route('GET', '/applications/{app_id}/embedded-activity-config', app_id=app_id), super_properties_to_track=True + ) + + def edit_embedded_activity_config( + self, + app_id: Snowflake, + *, + supported_platforms: Optional[List[str]] = None, + orientation_lock_state: Optional[int] = None, + preview_video_asset_id: Optional[Snowflake] = MISSING, + ) -> Response[appinfo.EmbeddedActivityConfig]: + payload = {} + if supported_platforms is not None: + payload['supported_platforms'] = supported_platforms + if orientation_lock_state is not None: + payload['default_orientation_lock_state'] = orientation_lock_state + if preview_video_asset_id is not MISSING: + payload['activity_preview_video_asset_id'] = preview_video_asset_id - return self.request(r, params=params, super_properties_to_track=True) + return self.request( + Route('PATCH', '/applications/{app_id}/embedded-activity-config', app_id=app_id), + json=payload, + super_properties_to_track=True, + ) - def get_app_whitelist(self, app_id): + def get_app_whitelisted(self, app_id: Snowflake) -> Response[List[appinfo.WhitelistedUser]]: return self.request( Route('GET', '/oauth2/applications/{app_id}/allowlist', app_id=app_id), super_properties_to_track=True ) + def add_app_whitelist(self, app_id: Snowflake, username: str, discriminator: str) -> Response[appinfo.WhitelistedUser]: + payload = {'username': username, 'discriminator': discriminator} + + return self.request( + Route('POST', '/oauth2/applications/{app_id}/allowlist', app_id=app_id), + json=payload, + super_properties_to_track=True, + ) + + def delete_app_whitelist(self, app_id: Snowflake, user_id: Snowflake): + return self.request( + Route('DELETE', '/oauth2/applications/{app_id}/allowlist/{user_id}', app_id=app_id, user_id=user_id), + super_properties_to_track=True, + ) + + def get_app_assets(self, app_id: Snowflake) -> Response[List[appinfo.Asset]]: + return self.request(Route('GET', '/oauth2/applications/{app_id}/assets', app_id=app_id)) + + def get_store_assets(self, app_id: Snowflake) -> Response[List[appinfo.StoreAsset]]: + return self.request( + Route('GET', '/store/applications/{app_id}/assets', app_id=app_id), super_properties_to_track=True + ) + + def create_asset(self, app_id: Snowflake, name: str, type: int, image: str) -> Response[appinfo.Asset]: + payload = {'name': name, 'type': type, 'image': image} + + return self.request( + Route('POST', '/oauth2/applications/{app_id}/assets', app_id=app_id), + json=payload, + super_properties_to_track=True, + ) + + def create_store_asset(self, app_id: Snowflake, file: File) -> Response[appinfo.StoreAsset]: + initial_bytes = file.fp.read(16) + + try: + mime_type = utils._get_mime_type_for_image(initial_bytes, True) + except ValueError: + if initial_bytes.startswith(b'{'): + mime_type = 'application/json' + else: + mime_type = 'application/octet-stream' + finally: + file.reset() + + form: List[Dict[str, Any]] = [ + { + 'name': 'assets', # Not a typo + 'value': file.fp, + 'filename': file.filename, + 'content_type': mime_type, + } + ] + + return self.request(Route('POST', '/store/applications/{app_id}/assets', app_id=app_id), form=form, files=[file]) + + def delete_asset(self, app_id: Snowflake, asset_id: Snowflake) -> Response[None]: + return self.request( + Route('DELETE', '/oauth2/applications/{app_id}/assets/{asset_id}', app_id=app_id, asset_id=asset_id), + super_properties_to_track=True, + ) + + def delete_store_asset(self, app_id: Snowflake, asset_id: Snowflake) -> Response[None]: + return self.request( + Route('DELETE', '/store/applications/{app_id}/assets/{asset_id}', app_id=app_id, asset_id=asset_id), + super_properties_to_track=True, + ) + def create_team(self, name: str): payload = {'name': name} return self.request(Route('POST', '/teams'), json=payload, super_properties_to_track=True) - def get_teams(self) -> Response[List[team.Team]]: - return self.request(Route('GET', '/teams'), super_properties_to_track=True) + def get_teams(self, *, include_payout_account_status: bool = False) -> Response[List[team.Team]]: + params = {} + if include_payout_account_status: + params['include_payout_account_status'] = 'true' + + return self.request(Route('GET', '/teams'), params=params) def get_team(self, team_id: Snowflake) -> Response[team.Team]: return self.request(Route('GET', '/teams/{team_id}', team_id=team_id), super_properties_to_track=True) - def edit_team(self, team_id: Snowflake, payload) -> Response[team.Team]: + def edit_team(self, team_id: Snowflake, payload: dict) -> Response[team.Team]: return self.request( Route('PATCH', '/teams/{team_id}', team_id=team_id), json=payload, super_properties_to_track=True ) def delete_team(self, team_id: Snowflake) -> Response[None]: - return self.request(Route('POST', '/teams/{app_id}/delete', team_id=team_id), super_properties_to_track=True) + return self.request(Route('POST', '/teams/{team_id}/delete', team_id=team_id), super_properties_to_track=True) - def get_team_applications(self, team_id: Snowflake) -> Response[List[appinfo.AppInfo]]: + def get_team_applications(self, team_id: Snowflake) -> Response[List[appinfo.Application]]: return self.request(Route('GET', '/teams/{team_id}/applications', team_id=team_id), super_properties_to_track=True) def get_team_members(self, team_id: Snowflake) -> Response[List[team.TeamMember]]: @@ -2350,87 +2662,1054 @@ class HTTPClient: super_properties_to_track=True, ) + def create_team_company(self, team_id: Snowflake, name: str) -> Response[appinfo.Company]: + payload = {'name': name} + + return self.request( + Route('POST', '/teams/{team_id}/companies', team_id=team_id), json=payload, super_properties_to_track=True + ) + + def search_companies(self, query: str) -> Response[List[appinfo.Company]]: + # This endpoint 204s without a query? + params = {'query': query} + data = self.request(Route('GET', '/companies'), params=params, super_properties_to_track=True) + return data or [] + + def get_team_payouts( + self, team_id: Snowflake, *, limit: int = 96, before: Optional[Snowflake] = None + ) -> Response[List[team.TeamPayout]]: + params: Dict[str, Any] = {'limit': limit} + if before is not None: + params['before'] = before + + return self.request( + Route('GET', '/teams/{team_id}/payouts', team_id=team_id), params=params, super_properties_to_track=True + ) + + def get_team_payout_report(self, team_id: Snowflake, payout_id: Snowflake, type: str) -> Response[bytes]: + params = {'type': type} + return self.request( + Route('GET', '/teams/{team_id}/payouts/{payout_id}/report', team_id=team_id, payout_id=payout_id), + params=params, + super_properties_to_track=True, + ) + def botify_app(self, app_id: Snowflake) -> Response[None]: - return self.request(Route('POST', '/applications/{app_id}/bot', app_id=app_id), super_properties_to_track=True) + return self.request( + Route('POST', '/applications/{app_id}/bot', app_id=app_id), json={}, super_properties_to_track=True + ) def edit_bot(self, app_id: Snowflake, payload: dict) -> Response[user.User]: return self.request( Route('PATCH', '/applications/{app_id}/bot', app_id=app_id), json=payload, super_properties_to_track=True ) - def reset_secret(self, app_id: Snowflake) -> Response[appinfo.AppInfo]: + def reset_secret(self, app_id: Snowflake) -> Response[dict]: return self.request(Route('POST', '/applications/{app_id}/reset', app_id=app_id), super_properties_to_track=True) - def reset_token(self, app_id: Snowflake): + def reset_bot_token(self, app_id: Snowflake) -> Response[dict]: return self.request(Route('POST', '/applications/{app_id}/bot/reset', app_id=app_id), super_properties_to_track=True) - def get_detectable_applications(self) -> Response[List[appinfo.PartialAppInfo]]: + def get_detectable_applications(self) -> Response[List[appinfo.PartialApplication]]: return self.request(Route('GET', '/applications/detectable')) - # Misc + def get_guild_applications( + self, + guild_id: Snowflake, + *, + type: Optional[int] = None, + include_team: bool = False, + channel_id: Optional[Snowflake] = None, + ) -> Response[List[appinfo.PartialApplication]]: + params = {} + if type is not None: + params['type'] = type + if include_team: + params['include_team'] = 'true' + if channel_id is not None: + params['channel_id'] = channel_id - async def get_gateway(self, *, encoding: str = 'json', zlib: bool = True) -> str: - # The gateway URL hasn't changed for over 5 years - # And, the official clients aren't GETting it anymore, sooooo... - self.zlib = zlib - if zlib: - value = 'wss://gateway.discord.gg?encoding={0}&v=9&compress=zlib-stream' - else: - value = 'wss://gateway.discord.gg?encoding={0}&v=9' + return self.request(Route('GET', '/guilds/{guild_id}/applications', guild_id=guild_id), params=params) - return value.format(encoding) + def get_app_ticket(self, app_id: Snowflake, test_mode: bool = False) -> Response[appinfo.Ticket]: + payload = {'test_mode': test_mode} - def get_user(self, user_id: Snowflake) -> Response[user.User]: - return self.request(Route('GET', '/users/{user_id}', user_id=user_id)) + return self.request(Route('POST', '/users/@me/applications/{app_id}/ticket', app_id=app_id), json=payload) - def get_user_profile( - self, user_id: Snowflake, guild_id: Snowflake = MISSING, *, with_mutual_guilds: bool = True - ): # TODO: return type - params: Dict[str, Any] = {'with_mutual_guilds': str(with_mutual_guilds).lower()} - if guild_id is not MISSING: - params['guild_id'] = guild_id + def get_app_entitlement_ticket(self, app_id: Snowflake, test_mode: bool = False) -> Response[appinfo.Ticket]: + payload = {'test_mode': test_mode} - return self.request(Route('GET', '/users/{user_id}/profile', user_id=user_id), params=params) + return self.request( + Route('POST', '/users/@me/applications/{app_id}/entitlement-ticket', app_id=app_id), json=payload + ) - def get_mutual_friends(self, user_id: Snowflake): # TODO: return type - return self.request(Route('GET', '/users/{user_id}/relationships', user_id=user_id)) + def get_app_activity_statistics(self, app_id: Snowflake) -> Response[List[appinfo.ActivityStatistics]]: + return self.request(Route('GET', '/activities/statistics/applications/{app_id}', app_id=app_id)) - def get_notes(self): # TODO: return type - return self.request(Route('GET', '/users/@me/notes')) + def get_activity_statistics(self) -> Response[List[appinfo.ActivityStatistics]]: + return self.request(Route('GET', '/users/@me/activities/statistics/applications')) - def get_note(self, user_id: Snowflake): # TODO: return type - return self.request(Route('GET', '/users/@me/notes/{user_id}', user_id=user_id)) + def get_global_activity_statistics(self) -> Response[List[appinfo.GlobalActivityStatistics]]: + return self.request(Route('GET', '/activities')) - def set_note(self, user_id: Snowflake, *, note: Optional[str] = None) -> Response[None]: - payload = {'note': note or ''} + def get_app_manifest_labels(self, app_id: Snowflake) -> Response[List[appinfo.ManifestLabel]]: + return self.request( + Route('GET', '/applications/{app_id}/manifest-labels', app_id=app_id), super_properties_to_track=True + ) - return self.request(Route('PUT', '/users/@me/notes/{user_id}', user_id=user_id), json=payload) + def get_app_branches(self, app_id: Snowflake) -> Response[List[appinfo.Branch]]: + return self.request(Route('GET', '/applications/{app_id}/branches', app_id=app_id)) - def change_hypesquad_house(self, house_id: int) -> Response[None]: - payload = {'house_id': house_id} + def create_app_branch(self, app_id: Snowflake, name: str) -> Response[appinfo.Branch]: + payload = {'name': name} - return self.request(Route('POST', '/hypesquad/online'), json=payload) + return self.request(Route('POST', '/applications/{app_id}/branches', app_id=app_id), json=payload) - def leave_hypesquad_house(self) -> Response[None]: - return self.request(Route('DELETE', '/hypesquad/online')) + def delete_app_branch(self, app_id: Snowflake, branch_id: Snowflake) -> Response[None]: + return self.request( + Route('DELETE', '/applications/{app_id}/branches/{branch_id}', app_id=app_id, branch_id=branch_id) + ) - def get_settings(self): # TODO: return type - return self.request(Route('GET', '/users/@me/settings')) + def get_branch_builds(self, app_id: Snowflake, branch_id: Snowflake) -> Response[List[appinfo.Build]]: + return self.request( + Route('GET', '/applications/{app_id}/branches/{branch_id}/builds', app_id=app_id, branch_id=branch_id) + ) - def edit_settings(self, **payload): # TODO: return type, is this cheating? - return self.request(Route('PATCH', '/users/@me/settings'), json=payload) + def get_branch_build(self, app_id: Snowflake, branch_id: Snowflake, build_id: Snowflake) -> Response[appinfo.Build]: + return self.request( + Route( + 'GET', + '/applications/{app_id}/branches/{branch_id}/builds/{build_id}', + app_id=app_id, + branch_id=branch_id, + build_id=build_id, + ) + ) - def get_tracking(self): # TODO: return type - return self.request(Route('GET', '/users/@me/consent')) + def get_latest_branch_build(self, app_id: Snowflake, branch_id: Snowflake) -> Response[appinfo.Build]: + return self.request( + Route('GET', '/applications/{app_id}/branches/{branch_id}/builds/latest', app_id=app_id, branch_id=branch_id) + ) - def edit_tracking(self, payload): - return self.request(Route('POST', '/users/@me/consent'), json=payload) + def get_live_branch_build( + self, app_id: Snowflake, branch_id: Snowflake, locale: str, platform: str + ) -> Response[appinfo.Build]: + params = {'locale': locale, 'platform': platform} + return self.request( + Route('GET', '/applications/{app_id}/branches/{branch_id}/builds/live', app_id=app_id, branch_id=branch_id), + params=params, + ) - def get_email_settings(self): - return self.request(Route('GET', '/users/@me/email-settings')) + def get_build_ids(self, branch_ids: Sequence[Snowflake]) -> Response[List[appinfo.Branch]]: + payload = {'branch_ids': branch_ids} - def edit_email_settings(self, **payload): - return self.request(Route('PATCH', '/users/@me/email-settings'), json={'settings': payload}) + return self.request(Route('POST', '/branches'), json=payload) + + def create_branch_build(self, app_id: Snowflake, branch_id: Snowflake, payload: dict) -> Response[appinfo.CreatedBuild]: + return self.request( + Route('POST', '/applications/{app_id}/branches/{branch_id}/builds', app_id=app_id, branch_id=branch_id), + json=payload, + ) + + def edit_build(self, app_id: Snowflake, build_id: Snowflake, status: str) -> Response[None]: + payload = {'status': status} + + return self.request( + Route('PATCH', '/applications/{app_id}/builds/{build_id}', app_id=app_id, build_id=build_id), json=payload + ) + + def delete_build(self, app_id: Snowflake, build_id: Snowflake) -> Response[None]: + return self.request(Route('DELETE', '/applications/{app_id}/builds/{build_id}', app_id=app_id, build_id=build_id)) + + def get_branch_build_size( + self, app_id: Snowflake, branch_id: Snowflake, build_id: Snowflake, manifest_ids: Sequence[Snowflake] + ) -> Response[appinfo.BranchSize]: + payload = {'manifest_ids': manifest_ids} + + return self.request( + Route( + 'POST', + '/applications/{app_id}/branches/{branch_id}/builds/{build_id}/size', + app_id=app_id, + branch_id=branch_id, + build_id=build_id, + ), + json=payload, + ) + + def get_branch_build_download_signatures( + self, app_id: Snowflake, branch_id: Snowflake, build_id: Snowflake, manifest_label_ids: Sequence[Snowflake] + ) -> Response[Dict[str, appinfo.DownloadSignature]]: + params = {'branch_id': branch_id, 'build_id': build_id} + payload = {'manifest_label_ids': manifest_label_ids} + + return self.request( + Route( + 'POST', + '/applications/{app_id}/download-signatures', + app_id=app_id, + ), + params=params, + json=payload, + ) + + def get_build_upload_urls( + self, app_id: Snowflake, build_id: Snowflake, files: Sequence[File], hash: bool = True + ) -> Response[List[appinfo.CreatedBuildFile]]: + payload = {'files': []} + for file in files: + # We create a new ID and set it as the filename + id = ''.join(choices(string.ascii_letters + string.digits, k=32)).upper() + file.filename = id + data = {'id': file.filename} + if hash: + data['md5_hash'] = file.md5 + + payload['files'].append(data) + + return self.request( + Route('POST', '/applications/{app_id}/builds/{build_id}/files', app_id=app_id, build_id=build_id), json=payload + ) + + def publish_build(self, app_id: Snowflake, branch_id: Snowflake, build_id: Snowflake) -> Response[None]: + return self.request( + Route( + 'POST', + '/applications/{app_id}/branches/{branch_id}/builds/{build_id}/publish', + app_id=app_id, + branch_id=branch_id, + build_id=build_id, + ) + ) + + def promote_build(self, app_id: Snowflake, branch_id: Snowflake, target_branch_id: Snowflake) -> Response[None]: + return self.request( + Route( + 'PUT', + '/applications/{app_id}/branches/{branch_id}/promote/{target_branch_id}', + app_id=app_id, + branch_id=branch_id, + target_branch_id=target_branch_id, + ) + ) + + def get_store_listing( + self, + listing_id: Snowflake, + *, + country_code: Optional[str] = None, + payment_source_id: Optional[Snowflake] = None, + localize: bool = True, + ) -> Response[store.StoreListing]: + params = {} + if country_code: + params['country_code'] = country_code + if payment_source_id: + params['payment_source_id'] = payment_source_id + if not localize: + params['localize'] = 'false' + + return self.request(Route('GET', '/store/listings/{listing_id}', app_id=listing_id), params=params) + + def get_store_listing_by_sku( + self, + sku_id: Snowflake, + *, + country_code: Optional[str] = None, + payment_source_id: Optional[Snowflake] = None, + localize: bool = True, + ) -> Response[store.StoreListing]: + params = {} + if country_code: + params['country_code'] = country_code + if payment_source_id: + params['payment_source_id'] = payment_source_id + if not localize: + params['localize'] = 'false' + + return self.request(Route('GET', '/store/published-listings/skus/{sku_id}', sku_id=sku_id), params=params) + + def get_sku_store_listings( + self, + sku_id: Snowflake, + *, + country_code: Optional[str] = None, + payment_source_id: Optional[int] = None, + localize: bool = True, + ) -> Response[List[store.StoreListing]]: + params = {} + if country_code: + params['country_code'] = country_code + if payment_source_id: + params['payment_source_id'] = payment_source_id + if not localize: + params['localize'] = 'false' + + return self.request(Route('GET', '/store/skus/{sku_id}/listings', sku_id=sku_id), params=params) + + def get_store_listing_subscription_plans( + self, + sku_id: Snowflake, + *, + country_code: Optional[str] = None, + payment_source_id: Optional[Snowflake] = None, + include_unpublished: bool = False, + revenue_surface: Optional[int] = None, + ) -> Response[List[subscriptions.SubscriptionPlan]]: + params = {} + if country_code: + params['country_code'] = country_code + if payment_source_id: + params['payment_source_id'] = payment_source_id + if include_unpublished: + params['include_unpublished'] = 'true' + if revenue_surface: + params['revenue_surface'] = revenue_surface + + return self.request(Route('GET', '/store/published-listings/skus/{sku_id}/subscription-plans', sku_id=sku_id)) + + def get_store_listings_subscription_plans( + self, + sku_ids: Sequence[Snowflake], + *, + country_code: Optional[str] = None, + payment_source_id: Optional[Snowflake] = None, + include_unpublished: bool = False, + revenue_surface: Optional[int] = None, + ) -> Response[List[subscriptions.SubscriptionPlan]]: + params = {} + if country_code: + params['country_code'] = country_code + if payment_source_id: + params['payment_source_id'] = payment_source_id + if include_unpublished: + params['include_unpublished'] = 'true' + if revenue_surface: + params['revenue_surface'] = revenue_surface + + return self.request(Route('GET', '/store/published-listings/skus/subscription-plans'), params={'sku_ids': sku_ids}) + + def get_app_store_listings( + self, + app_id: Snowflake, + *, + country_code: Optional[str] = None, + payment_source_id: Optional[int] = None, + localize: bool = True, + ) -> Response[List[store.StoreListing]]: + params = {'application_id': app_id} + if country_code: + params['country_code'] = country_code + if payment_source_id: + params['payment_source_id'] = payment_source_id + if not localize: + params['localize'] = 'false' + + return self.request(Route('GET', '/store/published-listings/skus'), params=params) + + def get_app_store_listing( + self, + app_id: Snowflake, + *, + country_code: Optional[str] = None, + payment_source_id: Optional[int] = None, + localize: bool = True, + ) -> Response[store.StoreListing]: + params = {} + if country_code: + params['country_code'] = country_code + if payment_source_id: + params['payment_source_id'] = payment_source_id + if not localize: + params['localize'] = 'false' + + return self.request( + Route('GET', '/store/published-listings/applications/{application_id}', application_id=app_id), params=params + ) + + def get_apps_store_listing( + self, + app_ids: Sequence[Snowflake], + *, + country_code: Optional[str] = None, + payment_source_id: Optional[Snowflake] = None, + localize: bool = True, + ) -> Response[List[store.StoreListing]]: + params: Dict[str, Any] = {'application_ids': app_ids} + if country_code: + params['country_code'] = country_code + if payment_source_id: + params['payment_source_id'] = payment_source_id + if not localize: + params['localize'] = 'false' + + return self.request(Route('GET', '/store/published-listings/applications'), params=params) + + def create_store_listing( + self, application_id: Snowflake, sku_id: Snowflake, payload: dict + ) -> Response[store.StoreListing]: + return self.request( + Route('POST', '/store/listings'), + json={**payload, 'application_id': application_id, 'sku_id': sku_id}, + super_properties_to_track=True, + ) + + def edit_store_listing(self, listing_id: Snowflake, payload: dict) -> Response[store.StoreListing]: + return self.request( + Route('PATCH', '/store/listings/{listing_id}', listing_id=listing_id), + json=payload, + super_properties_to_track=True, + ) + + def get_sku( + self, + sku_id: Snowflake, + *, + country_code: Optional[str] = None, + payment_source_id: Optional[Snowflake] = None, + localize: bool = True, + ) -> Response[store.SKU]: + params = {} + if country_code: + params['country_code'] = country_code + if payment_source_id: + params['payment_source_id'] = payment_source_id + if not localize: + params['localize'] = 'false' + + return self.request(Route('GET', '/store/skus/{sku_id}', sku_id=sku_id), params=params) + + def edit_sku(self, sku_id: Snowflake, payload: dict) -> Response[store.SKU]: + return self.request( + Route('PATCH', '/store/skus/{sku_id}', sku_id=sku_id), json=payload, super_properties_to_track=True + ) + + def preview_sku_purchase( + self, + sku_id: Snowflake, + payment_source_id: Snowflake, + subscription_plan_id: Optional[Snowflake] = None, + *, + test_mode: bool = False, + ) -> Response[store.SKUPrice]: + params = {'payment_source_id': payment_source_id} + if subscription_plan_id: + params['subscription_plan_id'] = subscription_plan_id + if test_mode: + params['test_mode'] = 'true' + + return self.request( + Route('GET', '/store/skus/{sku_id}/purchase', sku_id=sku_id), + params=params, + context_properties=ContextProperties._empty(), + ) + + def purchase_sku( + self, + sku_id: Snowflake, + payment_source_id: Optional[Snowflake] = None, + *, + subscription_plan_id: Optional[Snowflake] = None, + expected_amount: Optional[int] = None, + expected_currency: Optional[str] = None, + gift: bool = False, + gift_style: Optional[int] = None, + test_mode: bool = False, + payment_source_token: Optional[str] = None, + purchase_token: Optional[str] = None, + return_url: Optional[str] = None, + gateway_checkout_context: Optional[str] = None, + ) -> Response[store.SKUPurchase]: + payload = { + 'gift': gift, + 'purchase_token': purchase_token, + 'gateway_checkout_context': gateway_checkout_context, + } + if payment_source_id: + payload['payment_source_id'] = payment_source_id + payload['payment_source_token'] = payment_source_token + if subscription_plan_id: + payload['sku_subscription_plan_id'] = subscription_plan_id + if expected_amount is not None: + payload['expected_amount'] = expected_amount + if expected_currency: + payload['expected_currency'] = expected_currency + if gift_style: + payload['gift_style'] = gift_style + if test_mode: + payload['test_mode'] = True + if return_url: + payload['return_url'] = return_url + + return self.request( + Route('POST', '/store/skus/{sku_id}/purchase', sku_id=sku_id), + json=payload, + context_properties=ContextProperties._empty(), + ) + + def create_sku_discount(self, sku_id: Snowflake, user_id: Snowflake, percent_off: int, ttl: int = 600) -> Response[None]: + payload = {'percent_off': percent_off, 'ttl': ttl} + return self.request( + Route('PUT', '/store/skus/{sku_id}/discounts/{user_id}', sku_id=sku_id, user_id=user_id), json=payload + ) + + def delete_sku_discount(self, sku_id: Snowflake, user_id: Snowflake) -> Response[None]: + return self.request(Route('DELETE', '/store/skus/{sku_id}/discounts/{user_id}', sku_id=sku_id, user_id=user_id)) + + def get_eula(self, eula_id: Snowflake) -> Response[appinfo.EULA]: + return self.request(Route('GET', '/store/eulas/{eula_id}', eula_id=eula_id)) + + def get_price_tiers(self, type: Optional[int] = None, guild_id: Optional[Snowflake] = None) -> Response[List[int]]: + params = {} + if type: + params['price_tier_type'] = type + if guild_id: + params['guild_id'] = guild_id + + return self.request(Route('GET', '/store/price-tiers'), params=params, super_properties_to_track=True) + + def get_price_tier(self, price_tier: Snowflake) -> Response[Dict[str, int]]: + return self.request( + Route('GET', '/store/price-tiers/{price_tier}', price_tier=price_tier), super_properties_to_track=True + ) + + def create_achievement( + self, + app_id: Snowflake, + *, + name: str, + name_localizations: Optional[Mapping[str, str]] = None, + description: str, + description_localizations: Optional[Mapping[str, str]] = None, + icon: str, + secure: bool, + secret: bool, + ) -> Response[appinfo.Achievement]: + payload = { + 'name': { + 'default': name, + 'localizations': {str(k): v for k, v in (name_localizations or {}).items()}, + }, + 'description': { + 'default': description, + 'localizations': {str(k): v for k, v in (description_localizations or {}).items()}, + }, + 'icon': icon, + 'secure': secure, + 'secret': secret, + } + + return self.request( + Route('POST', '/applications/{app_id}/achievements', app_id=app_id), json=payload, super_properties_to_track=True + ) + + def get_achievements(self, app_id: Snowflake) -> Response[List[appinfo.Achievement]]: + return self.request( + Route('GET', '/applications/{app_id}/achievements', app_id=app_id), super_properties_to_track=True + ) + + def get_my_achievements(self, app_id: Snowflake) -> Response[List[appinfo.Achievement]]: + return self.request(Route('GET', '/users/@me/applications/{app_id}/achievements', app_id=app_id)) + + def get_achievement(self, app_id: Snowflake, achievement_id: Snowflake) -> Response[appinfo.Achievement]: + return self.request( + Route( + 'GET', '/applications/{app_id}/achievements/{achievement_id}', app_id=app_id, achievement_id=achievement_id + ), + super_properties_to_track=True, + ) + + def edit_achievement(self, app_id: Snowflake, achievement_id: Snowflake, payload: dict) -> Response[appinfo.Achievement]: + return self.request( + Route( + 'PATCH', '/applications/{app_id}/achievements/{achievement_id}', app_id=app_id, achievement_id=achievement_id + ), + json=payload, + super_properties_to_track=True, + ) + + def update_user_achievement( + self, app_id: Snowflake, achievement_id: Snowflake, user_id: Snowflake, percent_complete: int + ) -> Response[None]: + payload = {'percent_complete': percent_complete} + + return self.request( + Route( + 'PUT', + '/users/{user_id}/applications/{app_id}/achievements/{achievement_id}', + user_id=user_id, + app_id=app_id, + achievement_id=achievement_id, + ), + json=payload, + ) + + def delete_achievement(self, app_id: Snowflake, achievement_id: Snowflake) -> Response[None]: + return self.request( + Route( + 'DELETE', + '/applications/{app_id}/achievements/{achievement_id}', + app_id=app_id, + achievement_id=achievement_id, + ), + super_properties_to_track=True, + ) + + def get_gift_batches(self, app_id: Snowflake) -> Response[List[entitlements.GiftBatch]]: + return self.request( + Route('GET', '/applications/{app_id}/gift-code-batches', app_id=app_id), super_properties_to_track=True + ) + + def get_gift_batch_csv(self, app_id: Snowflake, batch_id: Snowflake) -> Response[bytes]: + return self.request( + Route('GET', '/applications/{app_id}/gift-code-batches/{batch_id}', app_id=app_id, batch_id=batch_id), + super_properties_to_track=True, + ) + + def create_gift_batch( + self, + app_id: Snowflake, + sku_id: Snowflake, + amount: int, + description: str, + *, + entitlement_branches: Optional[Sequence[Snowflake]] = None, + entitlement_starts_at: Optional[str] = None, + entitlement_ends_at: Optional[str] = None, + ) -> Response[entitlements.GiftBatch]: + payload = { + 'sku_id': sku_id, + 'amount': str(amount), + 'description': description, + 'entitlement_branches': entitlement_branches or [], + 'entitlement_starts_at': entitlement_starts_at or '', + 'entitlement_ends_at': entitlement_ends_at or '', + } + + return self.request( + Route('POST', '/applications/{app_id}/gift-code-batches', app_id=app_id), + json=payload, + super_properties_to_track=True, + ) + + def get_gift( + self, + code: str, + country_code: Optional[str] = None, + payment_source_id: Optional[Snowflake] = None, + with_application: bool = False, + with_subscription_plan: bool = True, + ) -> Response[entitlements.Gift]: + params: Dict[str, Any] = { + 'with_application': str(with_application).lower(), + 'with_subscription_plan': str(with_subscription_plan).lower(), + } + if country_code: + params['country_code'] = country_code + if payment_source_id: + params['payment_source_id'] = payment_source_id + + return self.request(Route('GET', '/entitlements/gift-codes/{code}', code=code), params=params) + + def get_sku_gifts( + self, sku_id: Snowflake, subscription_plan_id: Optional[Snowflake] = None + ) -> Response[List[entitlements.Gift]]: + params: Dict[str, Any] = {'sku_id': sku_id} + if subscription_plan_id: + params['subscription_plan_id'] = subscription_plan_id + + return self.request(Route('GET', '/users/@me/entitlements/gift-codes'), params=params) + + def create_gift( + self, sku_id: Snowflake, *, subscription_plan_id: Optional[Snowflake] = None, gift_style: Optional[int] = None + ) -> Response[entitlements.Gift]: + payload: Dict[str, Any] = {'sku_id': sku_id} + if subscription_plan_id: + payload['subscription_plan_id'] = subscription_plan_id + if gift_style: + payload['gift_style'] = gift_style + + return self.request(Route('POST', '/users/@me/entitlements/gift-codes'), json=payload) + + def redeem_gift( + self, + code: str, + payment_source_id: Optional[Snowflake] = None, + channel_id: Optional[Snowflake] = None, + gateway_checkout_context: Optional[str] = None, + ) -> Response[entitlements.Entitlement]: + payload: Dict[str, Any] = {'channel_id': channel_id, 'gateway_checkout_context': gateway_checkout_context} + if payment_source_id: + payload['payment_source_id'] = payment_source_id + + return self.request(Route('POST', '/entitlements/gift-codes/{code}/redeem', code=code), json=payload) + + def delete_gift(self, code: str) -> Response[None]: + return self.request(Route('DELETE', '/users/@me/entitlements/gift-codes/{code}', code=code)) + + # Billing + + def get_payment_sources(self) -> Response[List[billing.PaymentSource]]: + return self.request(Route('GET', '/users/@me/billing/payment-sources')) + + def get_payment_source(self, source_id: Snowflake) -> Response[billing.PaymentSource]: + return self.request(Route('GET', '/users/@me/billing/payment-sources/{source_id}', source_id=source_id)) + + def create_payment_source( + self, + *, + token: str, + payment_gateway: int, + billing_address: dict, + billing_address_token: Optional[str] = None, + return_url: Optional[str] = None, + bank: Optional[str] = None, + ) -> Response[billing.PaymentSource]: + payload = { + 'token': token, + 'payment_gateway': int(payment_gateway), + 'billing_address': billing_address, + } + if billing_address_token: + payload['billing_address_token'] = billing_address_token + if return_url: + payload['return_url'] = return_url + if bank: + payload['bank'] = bank + + return self.request(Route('POST', '/users/@me/billing/payment-sources'), json=payload) + + def edit_payment_source(self, source_id: Snowflake, payload: dict) -> Response[billing.PaymentSource]: + return self.request( + Route('PATCH', '/users/@me/billing/payment-sources/{source_id}', source_id=source_id), json=payload + ) + + def delete_payment_source(self, source_id: Snowflake) -> Response[None]: + return self.request(Route('DELETE', '/users/@me/billing/payment-sources/{source_id}', source_id=source_id)) + + def validate_billing_address(self, address: dict) -> Response[billing.BillingAddressToken]: + payload = {'billing_address': address} + + return self.request(Route('POST', '/users/@me/billing/payment-sources/validate-billing-address'), json=payload) + + def get_subscriptions( + self, limit: Optional[int] = None, include_inactive: bool = False + ) -> Response[List[subscriptions.Subscription]]: + params = {} + if limit: + params['limit'] = limit + if include_inactive: + params['include_inactive'] = 'true' + + return self.request(Route('GET', '/users/@me/billing/subscriptions'), params=params) + + def get_subscription(self, subscription_id: Snowflake) -> Response[subscriptions.Subscription]: + return self.request( + Route('GET', '/users/@me/billing/subscriptions/{subscription_id}', subscription_id=subscription_id) + ) + + def create_subscription( + self, + items: List[dict], + payment_source_id: int, + currency: str, + *, + trial_id: Optional[Snowflake] = None, + payment_source_token: Optional[str] = None, + return_url: Optional[str] = None, + purchase_token: Optional[str] = None, + gateway_checkout_context: Optional[str] = None, + code: Optional[str] = None, + metadata: Optional[dict] = None, + ) -> Response[subscriptions.Subscription]: + payload = { + 'items': items, + 'payment_source_id': payment_source_id, + 'currency': currency, + 'trial_id': trial_id, + 'payment_source_token': payment_source_token, + 'return_url': return_url, + 'purchase_token': purchase_token, + 'gateway_checkout_context': gateway_checkout_context, + } + if code: + payload['code'] = code + if metadata: + payload['metadata'] = metadata + + return self.request(Route('POST', '/users/@me/billing/subscriptions'), json=payload) + + def edit_subscription( + self, + subscription_id: Snowflake, + location: Optional[Union[str, List[str]]] = None, + location_stack: Optional[Union[str, List[str]]] = None, + **payload: dict, + ) -> Response[subscriptions.Subscription]: + params = {} + if location: + params['location'] = location + if location_stack: + params['location_stack'] = location_stack + + return self.request( + Route('PATCH', '/users/@me/billing/subscriptions/{subscription_id}', subscription_id=subscription_id), + params=params, + json=payload, + ) + + def delete_subscription( + self, + subscription_id: Snowflake, + location: Optional[Union[str, List[str]]] = None, + location_stack: Optional[Union[str, List[str]]] = None, + ) -> Response[None]: + params = {} + if location: + params['location'] = location + if location_stack: + params['location_stack'] = location_stack + + return self.request( + Route('DELETE', '/users/@me/billing/subscriptions/{subscription_id}', subscription_id=subscription_id), + params=params, + ) + + def preview_subscriptions_update( + self, + items: List[dict], + currency: str, + payment_source_id: Optional[Snowflake] = None, + trial_id: Optional[Snowflake] = None, + apply_entitlements: bool = MISSING, + renewal: bool = MISSING, + code: Optional[str] = None, + metadata: Optional[dict] = None, + ) -> Response[subscriptions.SubscriptionInvoice]: + payload: Dict[str, Any] = { + 'items': items, + 'currency': currency, + 'payment_source_id': payment_source_id, + 'trial_id': trial_id, + } + if apply_entitlements is not MISSING: + payload['apply_entitlements'] = apply_entitlements + if renewal is not MISSING: + payload['renewal'] = renewal + if code: + payload['code'] = code + if metadata: + payload['metadata'] = metadata + + return self.request(Route('POST', '/users/@me/billing/subscriptions/preview'), json=payload) + + def get_subscription_preview(self, subscription_id: Snowflake) -> Response[subscriptions.SubscriptionInvoice]: + return self.request( + Route('GET', '/users/@me/billing/subscriptions/{subscription_id}/preview', subscription_id=subscription_id) + ) + + def preview_subscription_update( + self, subscription_id: Snowflake, **payload + ) -> Response[subscriptions.SubscriptionInvoice]: + return self.request( + Route('PATCH', '/users/@me/billing/subscriptions/{subscription_id}/preview', subscription_id=subscription_id), + json=payload, + ) + + def get_subscription_invoices(self, subscription_id: Snowflake) -> Response[List[subscriptions.SubscriptionInvoice]]: + return self.request( + Route('GET', '/users/@me/billing/subscriptions/{subscription_id}/invoices', subscription_id=subscription_id) + ) + + def get_applied_guild_subscriptions(self) -> Response[List[subscriptions.PremiumGuildSubscription]]: + return self.request(Route('GET', '/users/@me/guilds/premium/subscriptions')) + + def get_guild_subscriptions(self, guild_id: Snowflake) -> Response[List[subscriptions.PremiumGuildSubscription]]: + return self.request(Route('GET', '/guilds/{guild_id}/premium/subscriptions', guild_id=guild_id)) + + def delete_guild_subscription(self, guild_id: Snowflake, subscription_id: Snowflake) -> Response[None]: + return self.request( + Route( + 'DELETE', + '/guilds/{guild_id}/premium/subscriptions/{subscription_id}', + guild_id=guild_id, + subscription_id=subscription_id, + ) + ) + + def get_guild_subscriptions_cooldown(self) -> Response[subscriptions.PremiumGuildSubscriptionCooldown]: + return self.request(Route('GET', '/users/@me/guilds/premium/subscriptions/cooldown')) + + def get_guild_subscription_slots(self) -> Response[List[subscriptions.PremiumGuildSubscriptionSlot]]: + return self.request(Route('GET', '/users/@me/guilds/premium/subscription-slots')) + + def apply_guild_subscription_slots( + self, guild_id: Snowflake, slot_ids: Sequence[Snowflake] + ) -> Response[List[subscriptions.PremiumGuildSubscription]]: + payload = {'user_premium_guild_subscription_slot_ids': slot_ids} + + return self.request(Route('PUT', '/guilds/{guild_id}/premium/subscriptions', guild_id=guild_id), json=payload) + + def cancel_guild_subscription_slot(self, slot_id: Snowflake) -> Response[subscriptions.PremiumGuildSubscriptionSlot]: + return self.request(Route('POST', '/users/@me/guilds/premium/subscription-slots/{slot_id}/cancel', slot_id=slot_id)) + + def uncancel_guild_subscription_slot(self, slot_id: Snowflake) -> Response[subscriptions.PremiumGuildSubscriptionSlot]: + return self.request( + Route('POST', '/users/@me/guilds/premium/subscription-slots/{slot_id}/uncancel', slot_id=slot_id) + ) + + def pay_invoice( + self, + subscription_id: Snowflake, + invoice_id: Snowflake, + payment_source_id: Optional[Snowflake], + payment_source_token: Optional[str] = None, + currency: str = 'usd', + return_url: Optional[str] = None, + ) -> Response[subscriptions.Subscription]: + payload = { + 'payment_source_id': payment_source_id, + } + if payment_source_id: + payload.update( + { + 'payment_source_token': payment_source_token, + 'currency': currency, + 'return_url': return_url, + } + ) + + return self.request( + Route( + 'POST', + '/users/@me/billing/subscriptions/{subscription_id}/invoices/{invoice_id}/pay', + subscription_id=subscription_id, + invoice_id=invoice_id, + ), + json=payload, + ) + + def get_payments( + self, limit: int, before: Optional[Snowflake] = None, after: Optional[Snowflake] = None + ) -> Response[List[payments.Payment]]: + params: Dict[str, Snowflake] = {'limit': limit} + if before: + params['before'] = before + if after: + params['after'] = after + + return self.request(Route('GET', '/users/@me/billing/payments'), params=params) + + def get_payment(self, payment_id: Snowflake) -> Response[payments.Payment]: + return self.request(Route('GET', '/users/@me/billing/payments/{payment_id}', payment_id=payment_id)) + + def void_payment(self, payment_id: Snowflake) -> Response[None]: + return self.request(Route('POST', '/users/@me/billing/payments/{payment_id}/void', payment_id=payment_id)) + + def refund_payment(self, payment_id: Snowflake, reason: Optional[int] = None) -> Response[None]: + payload = {'reason': reason} + + return self.request( + Route('POST', '/users/@me/billing/payments/{payment_id}/refund', payment_id=payment_id), json=payload + ) + + def get_promotions(self, locale: str = 'en-US') -> Response[List[promotions.Promotion]]: + params = {'locale': locale} + return self.request(Route('GET', '/outbound-promotions'), params=params) + + def get_claimed_promotions(self, locale: str = 'en-US') -> Response[List[promotions.ClaimedPromotion]]: + params = {'locale': locale} + return self.request(Route('GET', '/users/@me/outbound-promotions/codes'), params=params) + + def claim_promotion(self, promotion_id: Snowflake) -> Response[promotions.ClaimedPromotion]: + return self.request(Route('POST', '/outbound-promotions/{promotion_id}/claim', promotion_id=promotion_id)) + + def get_trial_offer(self) -> Response[promotions.TrialOffer]: + return self.request(Route('GET', '/users/@me/billing/user-trial-offer')) + + def ack_trial_offer(self, trial_id: Snowflake) -> Response[None]: + return self.request(Route('POST', '/users/@me/billing/user-trial-offer/{trial_id}/ack', trial_id=trial_id)) + + def get_pricing_promotion(self) -> Response[promotions.WrappedPricingPromotion]: + return self.request(Route('GET', '/users/@me/billing/localized-pricing-promo')) + + def get_premium_usage(self) -> Response[billing.PremiumUsage]: + return self.request(Route('GET', '/users/@me/premium-usage')) + + def enroll_active_developer( + self, application_id: Snowflake, channel_id: Snowflake + ) -> Response[appinfo.ActiveDeveloperResponse]: + payload = {'application_id': application_id, 'channel_id': channel_id} + + return self.request(Route('POST', '/developers/active-program'), json=payload, super_properties_to_track=True) + + def unenroll_active_developer(self) -> Response[None]: + return self.request(Route('DELETE', '/developers/active-program'), super_properties_to_track=True) + + # Misc + + async def get_gateway(self, *, encoding: str = 'json', zlib: bool = True) -> str: + # The gateway URL hasn't changed for over 5 years + # And, the official clients aren't GETting it anymore, sooooo... + self.zlib = zlib + if zlib: + value = 'wss://gateway.discord.gg?encoding={0}&v=9&compress=zlib-stream' + else: + value = 'wss://gateway.discord.gg?encoding={0}&v=9' + + return value.format(encoding) + + def get_user(self, user_id: Snowflake) -> Response[user.User]: + return self.request(Route('GET', '/users/{user_id}', user_id=user_id)) + + def get_user_profile( + self, user_id: Snowflake, guild_id: Snowflake = MISSING, *, with_mutual_guilds: bool = True + ): # TODO: return type + params: Dict[str, Any] = {'with_mutual_guilds': str(with_mutual_guilds).lower()} + if guild_id is not MISSING: + params['guild_id'] = guild_id + + return self.request(Route('GET', '/users/{user_id}/profile', user_id=user_id), params=params) + + def get_mutual_friends(self, user_id: Snowflake): # TODO: return type + return self.request(Route('GET', '/users/{user_id}/relationships', user_id=user_id)) + + def get_notes(self): # TODO: return type + return self.request(Route('GET', '/users/@me/notes')) + + def get_note(self, user_id: Snowflake): # TODO: return type + return self.request(Route('GET', '/users/@me/notes/{user_id}', user_id=user_id)) + + def set_note(self, user_id: Snowflake, *, note: Optional[str] = None) -> Response[None]: + payload = {'note': note or ''} + + return self.request(Route('PUT', '/users/@me/notes/{user_id}', user_id=user_id), json=payload) + + def change_hypesquad_house(self, house_id: int) -> Response[None]: + payload = {'house_id': house_id} + + return self.request(Route('POST', '/hypesquad/online'), json=payload) + + def leave_hypesquad_house(self) -> Response[None]: + return self.request(Route('DELETE', '/hypesquad/online')) + + def get_settings(self): # TODO: return type + return self.request(Route('GET', '/users/@me/settings')) + + def edit_settings(self, **payload): # TODO: return type, is this cheating? + return self.request(Route('PATCH', '/users/@me/settings'), json=payload) + + def get_tracking(self): # TODO: return type + return self.request(Route('GET', '/users/@me/consent')) + + def edit_tracking(self, payload): + return self.request(Route('POST', '/users/@me/consent'), json=payload) + + def get_email_settings(self): + return self.request(Route('GET', '/users/@me/email-settings')) + + def edit_email_settings(self, **payload): + return self.request(Route('PATCH', '/users/@me/email-settings'), json={'settings': payload}) def mobile_report( # Report v1 self, guild_id: Snowflake, channel_id: Snowflake, message_id: Snowflake, reason: str @@ -2439,7 +3718,7 @@ class HTTPClient: return self.request(Route('POST', '/report'), json=payload) - def get_application_commands(self, app_id): + def get_application_commands(self, app_id: Snowflake): return self.request(Route('GET', '/applications/{application_id}/commands', application_id=app_id)) def search_application_commands( @@ -2520,9 +3799,43 @@ class HTTPClient: return self.request(Route('POST', '/interactions'), json=payload, form=form, files=files) - def get_country_code(self): + def get_country_code(self) -> Response[dict]: return self.request(Route('GET', '/users/@me/billing/country-code')) + def get_library_entries( + self, country_code: Optional[str] = None, payment_source_id: Optional[Snowflake] = None + ) -> Response[List[library.LibraryApplication]]: + params = {} + if country_code is not None: + params['country_code'] = country_code + if payment_source_id is not None: + params['payment_source_id'] = payment_source_id + + return self.request(Route('GET', '/users/@me/library'), params=params) + + def edit_library_entry( + self, app_id: Snowflake, branch_id: Snowflake, payload: dict + ) -> Response[library.LibraryApplication]: + return self.request( + Route('PATCH', '/users/@me/library/{application_id}/{branch_id}', application_id=app_id, branch_id=branch_id), + json=payload, + ) + + def delete_library_entry(self, app_id: Snowflake, branch_id: Snowflake) -> Response[None]: + return self.request( + Route('DELETE', '/users/@me/library/{application_id}/{branch_id}', application_id=app_id, branch_id=branch_id) + ) + + def mark_library_entry_installed(self, app_id: Snowflake, branch_id: Snowflake) -> Response[library.LibraryApplication]: + return self.request( + Route( + 'POST', + '/users/@me/library/{application_id}/{branch_id}/installed', + application_id=app_id, + branch_id=branch_id, + ) + ) + async def get_preferred_voice_regions(self) -> List[dict]: async with self.__session.get('https://latency.discord.media/rtc') as resp: if resp.status == 200: diff --git a/discord/integrations.py b/discord/integrations.py index d3a2ec244..eb75cb8f1 100644 --- a/discord/integrations.py +++ b/discord/integrations.py @@ -24,31 +24,32 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations -import datetime -from typing import Any, Dict, Optional, TYPE_CHECKING, Type, Tuple -from .utils import _get_as_snowflake, parse_time, MISSING +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Type + +from .enums import ExpireBehaviour, try_enum from .user import User -from .enums import try_enum, ExpireBehaviour +from .utils import MISSING, _get_as_snowflake, parse_time, utcnow __all__ = ( 'IntegrationAccount', - 'IntegrationApplication', 'Integration', 'StreamIntegration', 'BotIntegration', ) if TYPE_CHECKING: + from datetime import datetime + + from .appinfo import IntegrationApplication from .guild import Guild from .role import Role from .state import ConnectionState from .types.integration import ( - IntegrationAccount as IntegrationAccountPayload, - Integration as IntegrationPayload, - StreamIntegration as StreamIntegrationPayload, BotIntegration as BotIntegrationPayload, + Integration as IntegrationPayload, + IntegrationAccount as IntegrationAccountPayload, IntegrationType, - IntegrationApplication as IntegrationApplicationPayload, + StreamIntegration as StreamIntegrationPayload, ) @@ -89,13 +90,13 @@ class Integration: guild: :class:`Guild` The guild of the integration. type: :class:`str` - The integration type (i.e. Twitch). + The integration type. enabled: :class:`bool` Whether the integration is currently enabled. account: :class:`IntegrationAccount` The account linked to this integration. - user: :class:`User` - The user that added this integration. + user: Optional[:class:`User`] + The user that added this integration, if available. """ __slots__ = ( @@ -125,7 +126,7 @@ class Integration: user = data.get('user') self.user: Optional[User] = User(state=self._state, data=user) if user else None - self.enabled: bool = data['enabled'] + self.enabled: bool = data.get('enabled', True) async def delete(self, *, reason: Optional[str] = None) -> None: """|coro| @@ -150,6 +151,7 @@ class Integration: Deleting the integration failed. """ await self._state.http.delete_integration(self.guild.id, self.id, reason=reason) + self.enabled = False class StreamIntegration(Integration): @@ -171,14 +173,14 @@ class StreamIntegration(Integration): Whether the integration is currently enabled. syncing: :class:`bool` Where the integration is currently syncing. - enable_emoticons: Optional[:class:`bool`] + enable_emoticons: :class:`bool` Whether emoticons should be synced for this integration (currently twitch only). expire_behaviour: :class:`ExpireBehaviour` The behaviour of expiring subscribers. Aliased to ``expire_behavior`` as well. expire_grace_period: :class:`int` The grace period (in days) for expiring subscribers. - user: :class:`User` - The user for the integration. + user: Optional[:class:`User`] + The user for the integration, if available. account: :class:`IntegrationAccount` The integration account information. synced_at: :class:`datetime.datetime` @@ -198,14 +200,14 @@ class StreamIntegration(Integration): def _from_data(self, data: StreamIntegrationPayload) -> None: super()._from_data(data) - self.revoked: bool = data['revoked'] - self.expire_behaviour: ExpireBehaviour = try_enum(ExpireBehaviour, data['expire_behavior']) - self.expire_grace_period: int = data['expire_grace_period'] - self.synced_at: datetime.datetime = parse_time(data['synced_at']) + self.revoked: bool = data.get('revoked', False) + self.expire_behaviour: ExpireBehaviour = try_enum(ExpireBehaviour, data.get('expire_behaviour', 0)) + self.expire_grace_period: int = data.get('expire_grace_period', 1) + self.synced_at: datetime = parse_time(data['synced_at']) if 'synced_at' in data else utcnow() self._role_id: Optional[int] = _get_as_snowflake(data, 'role_id') - self.syncing: bool = data['syncing'] - self.enable_emoticons: bool = data['enable_emoticons'] - self.subscriber_count: int = data['subscriber_count'] + self.syncing: bool = data.get('syncing', False) + self.enable_emoticons: bool = data.get('enable_emoticons', True) + self.subscriber_count: int = data.get('subscriber_count', 0) @property def expire_behavior(self) -> ExpireBehaviour: @@ -232,10 +234,6 @@ class StreamIntegration(Integration): You must have the :attr:`~Permissions.manage_guild` permission to do this. - .. versionchanged:: 2.0 - This function will now raise :exc:`TypeError` instead of - ``InvalidArgument``. - Parameters ----------- expire_behaviour: :class:`ExpireBehaviour` @@ -251,15 +249,10 @@ class StreamIntegration(Integration): You do not have permission to edit the integration. HTTPException Editing the guild failed. - TypeError - ``expire_behaviour`` did not receive a :class:`ExpireBehaviour`. """ payload: Dict[str, Any] = {} if expire_behaviour is not MISSING: - if not isinstance(expire_behaviour, ExpireBehaviour): - raise TypeError('expire_behaviour field must be of type ExpireBehaviour') - - payload['expire_behavior'] = expire_behaviour.value + payload['expire_behavior'] = int(expire_behaviour) if expire_grace_period is not MISSING: payload['expire_grace_period'] = expire_grace_period @@ -267,8 +260,6 @@ class StreamIntegration(Integration): if enable_emoticons is not MISSING: payload['enable_emoticons'] = enable_emoticons - # This endpoint is undocumented. - # Unsure if it returns the data or not as a result await self._state.http.edit_integration(self.guild.id, self.id, **payload) async def sync(self) -> None: @@ -287,47 +278,54 @@ class StreamIntegration(Integration): Syncing the integration failed. """ await self._state.http.sync_integration(self.guild.id, self.id) - self.synced_at = datetime.datetime.now(datetime.timezone.utc) + self.synced_at = utcnow() + async def disable(self, *, reason: Optional[str] = None) -> None: + """|coro| -class IntegrationApplication: - """Represents an application for a bot integration. + Disables the integration. - .. versionadded:: 2.0 + This is an alias of :meth:`Integration.delete`. - Attributes - ---------- - id: :class:`int` - The ID for this application. - name: :class:`str` - The application's name. - icon: Optional[:class:`str`] - The application's icon hash. - description: :class:`str` - The application's description. Can be an empty string. - summary: :class:`str` - The summary of the application. Can be an empty string. - user: Optional[:class:`User`] - The bot user on this application. - """ + You must have the :attr:`~Permissions.manage_guild` permission to + do this. - __slots__ = ( - 'id', - 'name', - 'icon', - 'description', - 'summary', - 'user', - ) + Parameters + ----------- + reason: :class:`str` + The reason the integration was disabled. Shows up on the audit log. - def __init__(self, *, data: IntegrationApplicationPayload, state: ConnectionState) -> None: - self.id: int = int(data['id']) - self.name: str = data['name'] - self.icon: Optional[str] = data['icon'] - self.description: str = data['description'] - self.summary: str = data['summary'] - user = data.get('bot') - self.user: Optional[User] = User(state=state, data=user) if user else None + Raises + ------- + Forbidden + You do not have permission to disable the integration. + HTTPException + Disabling the integration failed. + """ + await self.delete(reason=reason) + + async def enable(self, *, reason: Optional[str] = None) -> None: + """|coro| + + Enables the integration. + + You must have the :attr:`~Permissions.manage_guild` permission to + do this. + + Parameters + ----------- + reason: :class:`str` + The reason the integration was enabled. Shows up on the audit log. + + Raises + ------- + Forbidden + You do not have permission to enable the integration. + HTTPException + Enabling the integration failed. + """ + await self._state.http.create_integration(self.guild.id, self.type, self.id, reason=reason) + self.enabled = True class BotIntegration(Integration): @@ -344,22 +342,30 @@ class BotIntegration(Integration): guild: :class:`Guild` The guild of the integration. type: :class:`str` - The integration type (i.e. Twitch). + The integration type (i.e. Discord). enabled: :class:`bool` Whether the integration is currently enabled. - user: :class:`User` - The user that added this integration. + user: Optional[:class:`User`] + The user that added this integration, if available. account: :class:`IntegrationAccount` The integration account information. - application: :class:`IntegrationApplication` - The application tied to this integration. + application_id: :class:`int` + The application ID of the integration. + application: Optional[:class:`IntegrationApplication`] + The application tied to this integration. Not available in some contexts. + scopes: List[:class:`str`] + The scopes the integration is authorized for. """ - __slots__ = ('application',) + __slots__ = ('application', 'application_id', 'scopes') def _from_data(self, data: BotIntegrationPayload) -> None: super()._from_data(data) - self.application: IntegrationApplication = IntegrationApplication(data=data['application'], state=self._state) + self.application: Optional[IntegrationApplication] = ( + self._state.create_integration_application(data['application']) if 'application' in data else None + ) + self.application_id = self.application.id if self.application else int(data['application_id']) # type: ignore # One or the other + self.scopes: List[str] = data.get('scopes', []) def _integration_factory(value: str) -> Tuple[Type[Integration], str]: diff --git a/discord/invite.py b/discord/invite.py index 8e017ffdd..5a35a22b0 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -53,6 +53,7 @@ if TYPE_CHECKING: from .state import ConnectionState from .guild import Guild from .abc import GuildChannel, PrivateChannel, Snowflake + from .channel import GroupChannel from .user import User from .appinfo import PartialApplication from .message import Message @@ -95,9 +96,13 @@ class PartialInviteChannel: The partial channel's ID. type: :class:`ChannelType` The partial channel's type. + recipients: Optional[List[:class:`str`]] + The partial channel's recipient names. This is only applicable to group DMs. + + .. versionadded:: 2.0 """ - __slots__ = ('_state', 'id', 'name', 'type', '_icon') + __slots__ = ('_state', 'id', 'name', 'type', 'recipients', '_icon') def __new__(cls, data: Optional[InviteChannelPayload], *args, **kwargs): if data is None: @@ -111,6 +116,9 @@ class PartialInviteChannel: self.id: int = int(data['id']) self.name: str = data['name'] self.type: ChannelType = try_enum(ChannelType, data['type']) + self.recipients: Optional[List[str]] = ( + [user['username'] for user in data.get('recipients', [])] if self.type == ChannelType.group else None + ) self._icon: Optional[str] = data.get('icon') def __str__(self) -> str: @@ -310,8 +318,6 @@ class Invite(Hashable): +------------------------------------+------------------------------------------------------------+ | :attr:`approximate_presence_count` | :meth:`Client.fetch_invite` with `with_counts` enabled | +------------------------------------+------------------------------------------------------------+ - | :attr:`expires_at` | :meth:`Client.fetch_invite` with `with_expiration` enabled | - +------------------------------------+------------------------------------------------------------+ If it's not in the table above then it is available by all methods. @@ -349,12 +355,12 @@ class Invite(Hashable): The approximate number of members currently active in the guild. This includes idle, dnd, online, and invisible members. Offline members are excluded. expires_at: Optional[:class:`datetime.datetime`] - The expiration date of the invite. If the value is ``None`` when received through - `Client.fetch_invite` with `with_expiration` enabled, the invite will never expire. + The expiration date of the invite. If the value is ``None`` (unless received through + `Client.fetch_invite` with `with_expiration` disabled), the invite will never expire. .. versionadded:: 2.0 - channel: Optional[Union[:class:`abc.GuildChannel`, :class:`Object`, :class:`PartialInviteChannel`]] + channel: Optional[Union[:class:`abc.GuildChannel`, :class:`GroupChannel`, :class:`Object`, :class:`PartialInviteChannel`]] The channel the invite is for. Can be ``None`` if not a guild invite. target_type: :class:`InviteTarget` The type of target for the voice channel invite. @@ -435,7 +441,7 @@ class Invite(Hashable): state: ConnectionState, data: InvitePayload, guild: Optional[Union[PartialInviteGuild, Guild]] = None, - channel: Optional[Union[PartialInviteChannel, GuildChannel]] = None, + channel: Optional[Union[PartialInviteChannel, GuildChannel, GroupChannel]] = None, welcome_screen: Optional[WelcomeScreen] = None, ): self._state: ConnectionState = state @@ -552,7 +558,7 @@ class Invite(Hashable): def _resolve_channel( self, data: Optional[InviteChannelPayload], - channel: Optional[Union[PartialInviteChannel, GuildChannel]] = None, + channel: Optional[Union[PartialInviteChannel, GuildChannel, GroupChannel]] = None, ) -> Optional[InviteChannelType]: if channel is not None: return channel diff --git a/discord/library.py b/discord/library.py new file mode 100644 index 000000000..36ea3feaa --- /dev/null +++ b/discord/library.py @@ -0,0 +1,280 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Dolfies + +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 TYPE_CHECKING, Any, List, Optional + +from .appinfo import ApplicationActivityStatistics, ApplicationBranch, PartialApplication +from .entitlements import Entitlement +from .enums import SKUType, try_enum +from .flags import LibraryApplicationFlags +from .mixins import Hashable +from .utils import MISSING, _get_as_snowflake, find, parse_date, parse_time + +if TYPE_CHECKING: + from datetime import date, datetime + + from .asset import Asset + from .state import ConnectionState + from .types.appinfo import Branch as BranchPayload + from .types.library import LibraryApplication as LibraryApplicationPayload + from .types.store import PartialSKU as PartialSKUPayload + +__all__ = ( + 'LibrarySKU', + 'LibraryApplication', +) + + +class LibrarySKU(Hashable): + """Represents a partial store SKU for a library entry. + + .. container:: operations + + .. describe:: x == y + + Checks if two library SKUs are equal. + + .. describe:: x != y + + Checks if two library SKUs are not equal. + + .. describe:: hash(x) + + Returns the library SKU's hash. + + .. versionadded:: 2.0 + + Attributes + ----------- + id: :class:`int` + The SKU's ID. + type: :class:`SKUType` + The type of the SKU. + preorder_release_date: Optional[:class:`datetime.date`] + The approximate date that the SKU will released for pre-order, if any. + preorder_released_at: Optional[:class:`datetime.datetime`] + The date that the SKU was released for pre-order, if any. + premium: :class:`bool` + Whether this SKU is provided for free to premium users. + """ + + __slots__ = ( + 'id', + 'type', + 'preorder_release_date', + 'preorder_released_at', + 'premium', + ) + + def __init__(self, data: PartialSKUPayload): + self.id: int = int(data['id']) + self.type: SKUType = try_enum(SKUType, data['type']) + self.preorder_release_date: Optional[date] = parse_date(data.get('preorder_approximate_release_date')) + self.preorder_released_at: Optional[datetime] = parse_time(data.get('preorder_release_at')) + self.premium: bool = data.get('premium', False) + + def __repr__(self) -> str: + return f'' + + +class LibraryApplication: + """Represents a library entry. + + .. container:: operations + + .. describe:: x == y + + Checks if two library entries are equal. + + .. describe:: x != y + + Checks if two library entries are not equal. + + .. describe:: hash(x) + + Returns the library entry's hash. + + .. describe:: str(x) + + Returns the library entry's name. + + .. versionadded:: 2.0 + + Attributes + ----------- + created_at: :class:`datetime.datetime` + When this library entry was created. + application: :class:`PartialApplication` + The application that this library entry is for. + sku_id: :class:`int` + The ID of the SKU that this library entry is for. + sku: :class:`LibrarySKU` + The SKU that this library entry is for. + entitlements: List[:class:`Entitlement`] + The entitlements that this library entry has. + branch_id: :class:`int` + The ID of the branch that this library entry installs. + branch: :class:`ApplicationBranch` + The branch that this library entry installs. + """ + + __slots__ = ( + 'created_at', + 'application', + 'sku_id', + 'sku', + 'entitlements', + 'branch_id', + 'branch', + '_flags', + '_state', + ) + + def __init__(self, *, state: ConnectionState, data: LibraryApplicationPayload): + self._state = state + self._update(data) + + def _update(self, data: LibraryApplicationPayload): + state = self._state + + self.created_at: datetime = parse_time(data['created_at']) + self.application: PartialApplication = PartialApplication(state=state, data=data['application']) + self.sku_id: int = int(data['sku_id']) + self.sku: LibrarySKU = LibrarySKU(data=data['sku']) + self.entitlements: List[Entitlement] = [Entitlement(state=state, data=e) for e in data.get('entitlements', [])] + self._flags = data.get('flags', 0) + + self.branch_id: int = int(data['branch_id']) + branch: Optional[BranchPayload] = data.get('branch') + if not branch: + branch = {'id': self.branch_id, 'name': 'master'} + self.branch: ApplicationBranch = ApplicationBranch(state=state, data=branch, application_id=self.application.id) + + def __repr__(self) -> str: + return f'' + + def __eq__(self, other: Any) -> bool: + if isinstance(other, LibraryApplication): + return self.application.id == other.application.id and self.branch_id == other.branch_id + return False + + def __ne__(self, other: Any) -> bool: + if isinstance(other, LibraryApplication): + return self.application.id != other.application.id or self.branch_id != other.branch_id + return True + + def __hash__(self) -> int: + return hash((self.application.id, self.branch_id)) + + def __str__(self) -> str: + return self.application.name + + @property + def name(self) -> str: + """:class:`str`: The library entry's name.""" + return self.application.name + + @property + def icon(self) -> Optional[Asset]: + """:class:`Asset`: The library entry's icon asset, if any.""" + return self.application.icon + + @property + def flags(self) -> LibraryApplicationFlags: + """:class:`LibraryApplicationFlags`: The library entry's flags.""" + return LibraryApplicationFlags._from_value(self._flags) + + async def activity_statistics(self) -> ApplicationActivityStatistics: + """|coro| + + Gets the activity statistics for this library entry. + + Raises + ------- + HTTPException + Getting the activity statistics failed. + + Returns + -------- + :class:`ApplicationActivityStatistics` + The activity statistics for this library entry. + """ + state = self._state + data = await state.http.get_activity_statistics() + app = find(lambda a: _get_as_snowflake(a, 'application_id') == self.application.id, data) + return ApplicationActivityStatistics( + data=app + or {'application_id': self.application.id, 'total_duration': 0, 'last_played_at': '1970-01-01T00:00:00+00:00'}, + state=state, + ) + + async def mark_installed(self) -> None: + """|coro| + + Marks the library entry as installed. + + Raises + ------- + HTTPException + Marking the library entry as installed failed. + """ + await self._state.http.mark_library_entry_installed(self.application.id, self.branch_id) + + async def edit(self, *, flags: LibraryApplicationFlags = MISSING) -> None: + """|coro| + + Edits the library entry. + + All parameters are optional. + + Parameters + ----------- + flags: :class:`LibraryApplicationFlags` + The new flags to set for the library entry. + + Raises + ------- + HTTPException + Editing the library entry failed. + """ + payload = {} + if flags is not MISSING: + payload['flags'] = flags.value + + data = await self._state.http.edit_library_entry(self.application.id, self.branch_id, payload) + self._update(data) + + async def delete(self) -> None: + """|coro| + + Deletes the library entry. + + Raises + ------- + HTTPException + Deleting the library entry failed. + """ + await self._state.http.delete_library_entry(self.application.id, self.branch_id) diff --git a/discord/member.py b/discord/member.py index c01b377f1..5a81ffd7f 100644 --- a/discord/member.py +++ b/discord/member.py @@ -65,7 +65,7 @@ if TYPE_CHECKING: UserWithMember as UserWithMemberPayload, ) from .types.gateway import GuildMemberUpdateEvent - from .types.user import User as UserPayload + from .types.user import PartialUser as PartialUserPayload from .abc import Snowflake from .state import ConnectionState from .message import Message @@ -106,7 +106,7 @@ class VoiceState: suppress: :class:`bool` Indicates if the user is suppressed from speaking. - Only applies to stage channels. + Only applicable to stage channels. .. versionadded:: 1.7 @@ -440,7 +440,7 @@ class Member(discord.abc.Messageable, discord.abc.Connectable, _UserTag): if any(getattr(self, attr) != getattr(old, attr) for attr in attrs): return old - def _presence_update(self, data: PartialPresenceUpdate, user: UserPayload) -> Optional[Tuple[User, User]]: + def _presence_update(self, data: PartialPresenceUpdate, user: PartialUserPayload) -> Optional[Tuple[User, User]]: if self._self: return @@ -450,7 +450,7 @@ class Member(discord.abc.Messageable, discord.abc.Connectable, _UserTag): if len(user) > 1: return self._update_inner_user(user) - def _update_inner_user(self, user: UserPayload) -> Optional[Tuple[User, User]]: + def _update_inner_user(self, user: PartialUserPayload) -> Optional[Tuple[User, User]]: u = self._user original = (u.name, u._avatar, u.discriminator, u._public_flags) # These keys seem to always be available diff --git a/discord/metadata.py b/discord/metadata.py new file mode 100644 index 000000000..bf5fd4468 --- /dev/null +++ b/discord/metadata.py @@ -0,0 +1,134 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Dolfies + +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 TYPE_CHECKING, Any, Dict, Iterator, Optional, Tuple, Union + +from .utils import parse_time + + +class Metadata: + """Represents a raw model from Discord. + + Because of how unstable and wildly varying some metadata in Discord can be, this is a simple class + that just provides access to the raw data using dot notation. This means that ``None`` is returned + for unknown attributes instead of raising an exception. This class can be used similarly to a dictionary. + + .. versionadded:: 2.0 + + .. container:: operations + + .. describe:: x == y + + Checks if two metadata objects are equal. + + .. describe:: x != y + + Checks if two metadata objects are not equal. + + .. describe:: x[key] + + Returns a metadata value if it is found, otherwise raises a :exc:`KeyError`. + + .. describe:: key in x + + Checks if a metadata value is present. + + .. describe:: len(x) + + Returns the number of metadata values present. + + .. describe:: iter(x) + Returns an iterator of ``(field, value)`` pairs. This allows this class + to be used as an iterable in list/dict/etc constructions. + """ + + def __init__(self, data: Optional[MetadataObject] = None) -> None: + if not data: + return + + for key, value in data.items(): + if isinstance(value, dict): + value = Metadata(value) + elif key.endswith('_id') and isinstance(value, str) and value.isdigit(): + value = int(value) + elif (key.endswith('_at') or key.endswith('_date')) and isinstance(value, str): + try: + value = parse_time(value) + except ValueError: + pass + elif isinstance(value, list): + value = [Metadata(x) if isinstance(x, dict) else x for x in value] + + self.__dict__[key] = value + + def __repr__(self) -> str: + if not self.__dict__: + return '' + return f'' + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Metadata): + return False + return self.__dict__ == other.__dict__ + + def __ne__(self, other: object) -> bool: + if not isinstance(other, Metadata): + return True + return self.__dict__ != other.__dict__ + + def __iter__(self) -> Iterator[Tuple[str, Any]]: + yield from self.__dict__.items() + + def __getitem__(self, key: str) -> Any: + return self.__dict__[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.__dict__[key] = value + + def __getattr__(self, _) -> Any: + return None + + def __contains__(self, key: str) -> bool: + return key in self.__dict__ + + def __len__(self) -> int: + return len(self.__dict__) + + def keys(self): + """A set-like object providing a view on the metadata's keys.""" + return self.__dict__.keys() + + def values(self): + """A set-like object providing a view on the metadata's values.""" + return self.__dict__.values() + + def items(self): + """A set-like object providing a view on the metadata's items.""" + return self.__dict__.items() + + +if TYPE_CHECKING: + MetadataObject = Union[Metadata, Dict[str, Any]] diff --git a/discord/modal.py b/discord/modal.py index a60e0cf76..7cc40b0ff 100644 --- a/discord/modal.py +++ b/discord/modal.py @@ -32,7 +32,7 @@ from .mixins import Hashable from .utils import _generate_nonce if TYPE_CHECKING: - from .appinfo import InteractionApplication + from .appinfo import IntegrationApplication from .components import ActionRow from .interactions import Interaction @@ -78,7 +78,7 @@ class Modal(Hashable): The ID of the modal that gets received during an interaction. components: List[:class:`Component`] A list of components in the modal. - application: :class:`InteractionApplication` + application: :class:`IntegrationApplication` The application that sent the modal. """ @@ -92,7 +92,7 @@ class Modal(Hashable): self.title: str = data.get('title', '') self.custom_id: str = data.get('custom_id', '') self.components: List[ActionRow] = [_component_factory(d) for d in data.get('components', [])] # type: ignore # Will always be rows here - self.application: InteractionApplication = interaction._state.create_interaction_application(data['application']) + self.application: IntegrationApplication = interaction._state.create_integration_application(data['application']) def __str__(self) -> str: return self.title diff --git a/discord/payments.py b/discord/payments.py new file mode 100644 index 000000000..6f278edd3 --- /dev/null +++ b/discord/payments.py @@ -0,0 +1,290 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Dolfies + +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 datetime import datetime +from typing import TYPE_CHECKING, List, Optional + +from .billing import PaymentSource +from .enums import ( + PaymentGateway, + PaymentStatus, + SubscriptionType, + try_enum, +) +from .flags import PaymentFlags +from .mixins import Hashable +from .store import SKU +from .subscriptions import Subscription +from .utils import _get_as_snowflake, parse_time + +if TYPE_CHECKING: + from .entitlements import Entitlement + from .state import ConnectionState + from .types.payments import ( + PartialPayment as PartialPaymentPayload, + Payment as PaymentPayload, + ) + +__all__ = ( + 'Payment', + 'EntitlementPayment', +) + + +class Payment(Hashable): + """Represents a payment to Discord. + + .. container:: operations + + .. describe:: x == y + + Checks if two payments are equal. + + .. describe:: x != y + + Checks if two payments are not equal. + + .. describe:: hash(x) + + Returns the payment's hash. + + .. describe:: str(x) + + Returns the payment's description. + + .. versionadded:: 2.0 + + Attributes + ---------- + id: :class:`int` + The ID of the payment. + amount: :class:`int` + The amount of the payment. + amount_refunded: :class:`int` + The amount refunded from the payment, if any. + tax: :class:`int` + The amount of tax paid. + tax_inclusive: :class:`bool` + Whether the amount is inclusive of all taxes. + currency: :class:`str` + The currency the payment was made in. + description: :class:`str` + What the payment was for. + status: :class:`PaymentStatus` + The status of the payment. + created_at: :class:`datetime.datetime` + The time the payment was made. + sku: Optional[:class:`SKU`] + The SKU the payment was for, if applicable. + sku_id: Optional[:class:`int`] + The ID of the SKU the payment was for, if applicable. + sku_price: Optional[:class:`int`] + The price of the SKU the payment was for, if applicable. + subscription_plan_id: Optional[:class:`int`] + The ID of the subscription plan the payment was for, if applicable. + subscription: Optional[:class:`Subscription`] + The subscription the payment was for, if applicable. + payment_source: Optional[:class:`PaymentSource`] + The payment source the payment was made with. + payment_gateway: Optional[:class:`PaymentGateway`] + The payment gateway the payment was made with, if applicable. + payment_gateway_payment_id: Optional[:class:`str`] + The ID of the payment on the payment gateway, if any. + invoice_url: Optional[:class:`str`] + The URL to download the VAT invoice for this payment, if available. + refund_invoices_urls: List[:class:`str`] + A list of URLs to download VAT credit notices for refunds on this payment, if available. + refund_disqualification_reasons: List[:class:`str`] + A list of reasons why the payment cannot be refunded, if any. + """ + + __slots__ = ( + 'id', + 'amount', + 'amount_refunded', + 'tax', + 'tax_inclusive', + 'currency', + 'description', + 'status', + 'created_at', + 'sku', + 'sku_id', + 'sku_price', + 'subscription_plan_id', + 'subscription', + 'payment_source', + 'payment_gateway', + 'payment_gateway_payment_id', + 'invoice_url', + 'refund_invoices_urls', + 'refund_disqualification_reasons', + '_flags', + '_state', + ) + + def __init__(self, *, data: PaymentPayload, state: ConnectionState): + self._state: ConnectionState = state + self._update(data) + + def _update(self, data: PaymentPayload) -> None: + state = self._state + + self.id: int = int(data['id']) + self.amount: int = data['amount'] + self.amount_refunded: int = data.get('amount_refunded') or 0 + self.tax: int = data.get('tax') or 0 + self.tax_inclusive: bool = data.get('tax_inclusive', True) + self.currency: str = data.get('currency', 'usd') + self.description: str = data['description'] + self.status: PaymentStatus = try_enum(PaymentStatus, data['status']) + self.created_at: datetime = parse_time(data['created_at']) + self.sku: Optional[SKU] = SKU(data=data['sku'], state=state) if 'sku' in data else None + self.sku_id: Optional[int] = _get_as_snowflake(data, 'sku_id') + self.sku_price: Optional[int] = data.get('sku_price') + self.subscription_plan_id: Optional[int] = _get_as_snowflake(data, 'sku_subscription_plan_id') + self.payment_gateway: Optional[PaymentGateway] = ( + try_enum(PaymentGateway, data['payment_gateway']) if 'payment_gateway' in data else None + ) + self.payment_gateway_payment_id: Optional[str] = data.get('payment_gateway_payment_id') + self.invoice_url: Optional[str] = data.get('downloadable_invoice') + self.refund_invoices_urls: List[str] = data.get('downloadable_refund_invoices', []) + self.refund_disqualification_reasons: List[str] = data.get('premium_refund_disqualification_reasons', []) + self._flags: int = data.get('flags', 0) + + # The subscription object does not include the payment source ID + self.payment_source: Optional[PaymentSource] = ( + PaymentSource(data=data['payment_source'], state=state) if 'payment_source' in data else None + ) + if 'subscription' in data and self.payment_source: + data['subscription']['payment_source_id'] = self.payment_source.id # type: ignore + self.subscription: Optional[Subscription] = ( + Subscription(data=data['subscription'], state=state) if 'subscription' in data else None + ) + + def __repr__(self) -> str: + return f'' + + def __str__(self) -> str: + return self.description + + def is_subscription(self) -> bool: + """:class:`bool`: Whether the payment was for a subscription.""" + return self.subscription is not None + + def is_premium_subscription(self) -> bool: + """:class:`bool`: Whether the payment was for a Discord premium subscription.""" + return self.subscription is not None and self.subscription.type == SubscriptionType.premium + + def is_premium_subscription_gift(self) -> bool: + """:class:`bool`: Whether the payment was for a Discord premium subscription gift.""" + return self.flags.gift and self.sku_id in self._state.premium_subscriptions_sku_ids.values() + + def is_purchased_externally(self) -> bool: + """:class:`bool`: Whether the payment was made externally.""" + return self.payment_gateway in (PaymentGateway.apple, PaymentGateway.google) + + @property + def flags(self) -> PaymentFlags: + """:class:`PaymentFlags`: Returns the payment's flags.""" + return PaymentFlags._from_value(self._flags) + + async def void(self) -> None: + """|coro| + + Void the payment. Only applicable for payments of status :attr:`PaymentStatus.pending`. + + Raises + ------ + HTTPException + Voiding the payment failed. + """ + await self._state.http.void_payment(self.id) + self.status = PaymentStatus.failed + + async def refund(self, reason: Optional[int] = None) -> None: + """|coro| + + Refund the payment. + + Raises + ------ + HTTPException + Refunding the payment failed. + """ + # reason here is an enum (0-8), but I was unable to find the enum values + # Either way, it's optional and this endpoint isn't really used anyway + await self._state.http.refund_payment(self.id, reason) + self.status = PaymentStatus.refunded + + +class EntitlementPayment(Hashable): + """Represents a partial payment for an entitlement. + + .. container:: operations + + .. describe:: x == y + + Checks if two payments are equal. + + .. describe:: x != y + + Checks if two payments are not equal. + + .. describe:: hash(x) + + Returns the payment's hash. + + .. versionadded:: 2.0 + + Attributes + ---------- + entitlement: :class:`Entitlement` + The entitlement the payment is for. + id: :class:`int` + The ID of the payment. + amount: :class:`int` + The amount of the payment. + tax: :class:`int` + The amount of tax paid. + tax_inclusive: :class:`bool` + Whether the amount is inclusive of all taxes. + currency: :class:`str` + The currency the payment was made in. + """ + + __slots__ = ('entitlement', 'id', 'amount', 'tax', 'tax_inclusive', 'currency') + + def __init__(self, *, data: PartialPaymentPayload, entitlement: Entitlement): + self.entitlement = entitlement + self.id: int = int(data['id']) + self.amount: int = data['amount'] + self.tax: int = data.get('tax') or 0 + self.tax_inclusive: bool = data.get('tax_inclusive', True) + self.currency: str = data.get('currency', 'usd') + + def __repr__(self) -> str: + return f'' diff --git a/discord/profile.py b/discord/profile.py index 4ae824420..e5b09a976 100644 --- a/discord/profile.py +++ b/discord/profile.py @@ -116,8 +116,6 @@ class Profile: class ApplicationProfile(Hashable): """Represents a Discord application profile. - .. versionadded:: 2.0 - .. container:: operations .. describe:: x == y @@ -132,6 +130,8 @@ class ApplicationProfile(Hashable): Return the applications's hash. + .. versionadded:: 2.0 + Attributes ------------ id: :class:`int` @@ -142,6 +142,7 @@ class ApplicationProfile(Hashable): A list of the IDs of the application's popular commands. primary_sku_id: Optional[:class:`int`] The application's primary SKU ID, if any. + This can be an application's game SKU, subscription SKU, etc. custom_install_url: Optional[:class:`str`] The custom URL to use for authorizing the application, if specified. install_params: Optional[:class:`ApplicationInstallParams`] @@ -158,7 +159,7 @@ class ApplicationProfile(Hashable): params = data.get('install_params') self.custom_install_url: Optional[str] = data.get('custom_install_url') self.install_params: Optional[ApplicationInstallParams] = ( - ApplicationInstallParams(self.id, params) if params else None + ApplicationInstallParams.from_application(self, params) if params else None ) def __repr__(self) -> str: @@ -174,9 +175,37 @@ class ApplicationProfile(Hashable): """:class:`str`: The URL to install the application.""" return self.custom_install_url or self.install_params.url if self.install_params else None + @property + def primary_sku_url(self) -> Optional[str]: + """:class:`str`: The URL to the primary SKU of the application, if any.""" + if self.primary_sku_id: + return f'https://discord.com/store/skus/{self.primary_sku_id}/unknown' + class UserProfile(Profile, User): - """Represents a Discord user's profile. This is a :class:`User` with extended attributes. + """Represents a Discord user's profile. + + This is a :class:`User` with extended attributes. + + .. container:: operations + + .. describe:: x == y + + Checks if two users are equal. + + .. describe:: x != y + + Checks if two users are not equal. + + .. describe:: hash(x) + + Return the user's hash. + + .. describe:: str(x) + + Returns the user's name with discriminator. + + .. versionadded:: 2.0 Attributes ----------- @@ -208,7 +237,31 @@ class UserProfile(Profile, User): class MemberProfile(Profile, Member): - """Represents a Discord member's profile. This is a :class:`Member` with extended attributes. + """Represents a Discord member's profile. + + This is a :class:`Member` with extended attributes. + + .. container:: operations + + .. describe:: x == y + + Checks if two members are equal. + Note that this works with :class:`User` instances too. + + .. describe:: x != y + + Checks if two members are not equal. + Note that this works with :class:`User` instances too. + + .. describe:: hash(x) + + Returns the member's hash. + + .. describe:: str(x) + + Returns the member's name with the discriminator. + + .. versionadded:: 2.0 Attributes ----------- @@ -225,13 +278,13 @@ class MemberProfile(Profile, Member): .. note:: This is renamed from :attr:`Member.premium_since` because of name collisions. premium_type: Optional[:class:`PremiumType`] - Specifies the type of premium a user has (i.e. Nitro, Nitro Classic, or Nitro Basic). Could be None if the user is not premium. + Specifies the type of premium a user has (i.e. Nitro, Nitro Classic, or Nitro Basic). Could be ``None`` if the user is not premium. premium_since: Optional[:class:`datetime.datetime`] An aware datetime object that specifies how long a user has been premium (had Nitro). ``None`` if the user is not a premium user. .. note:: - This is not the same as :attr:`Member.premium_since`. That is renamed to :attr:`guild_premium_since` + This is not the same as :attr:`Member.premium_since`. That is renamed to :attr:`guild_premium_since`. boosting_since: Optional[:class:`datetime.datetime`] An aware datetime object that specifies when a user first boosted any guild. connections: Optional[List[:class:`PartialConnection`]] diff --git a/discord/promotions.py b/discord/promotions.py new file mode 100644 index 000000000..6f7337662 --- /dev/null +++ b/discord/promotions.py @@ -0,0 +1,306 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Dolfies + +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 datetime import datetime +from typing import TYPE_CHECKING, List, Optional, Union + +from .enums import PaymentSourceType, try_enum +from .flags import PromotionFlags +from .mixins import Hashable +from .subscriptions import SubscriptionTrial +from .utils import _get_as_snowflake, parse_time, utcnow + +if TYPE_CHECKING: + from .state import ConnectionState + from .types.promotions import ( + ClaimedPromotion as ClaimedPromotionPayload, + Promotion as PromotionPayload, + TrialOffer as TrialOfferPayload, + PricingPromotion as PricingPromotionPayload, + ) + +__all__ = ( + 'Promotion', + 'TrialOffer', + 'PricingPromotion', +) + + +class Promotion(Hashable): + """Represents a Discord promotion. + + .. container:: operations + + .. describe:: x == y + + Checks if two promotions are equal. + + .. describe:: x != y + + Checks if two promotions are not equal. + + .. describe:: hash(x) + + Returns the promotion's hash. + + .. describe:: str(x) + + Returns the outbound promotion's name. + + .. versionadded:: 2.0 + + Attributes + ---------- + id: :class:`int` + The promotion ID. + trial_id: Optional[:class:`int`] + The trial ID of the inbound promotion, if applicable. + starts_at: :class:`datetime.datetime` + When the promotion starts. + ends_at: :class:`datetime.datetime` + When the promotion ends. + claimed_at: Optional[:class:`datetime.datetime`] + When the promotion was claimed. + Only available for claimed promotions. + code: Optional[:class:`str`] + The promotion's claim code. Only available for claimed promotions. + outbound_title: :class:`str` + The title of the outbound promotion. + outbound_description: :class:`str` + The description of the outbound promotion. + outbound_link: :class:`str` + The redemption page of the outbound promotion, used to claim it. + outbound_restricted_countries: List[:class:`str`] + The countries that the outbound promotion is not available in. + inbound_title: Optional[:class:`str`] + The title of the inbound promotion. This is usually Discord Nitro. + inbound_description: Optional[:class:`str`] + The description of the inbound promotion. + inbound_link: Optional[:class:`str`] + The Discord help center link of the inbound promotion. + inbound_restricted_countries: List[:class:`str`] + The countries that the inbound promotion is not available in. + terms_and_conditions: :class:`str` + The terms and conditions of the promotion. + """ + + __slots__ = ( + 'id', + 'trial_id', + 'starts_at', + 'ends_at', + 'claimed_at', + 'code', + 'outbound_title', + 'outbound_description', + 'outbound_link', + 'outbound_restricted_countries', + 'inbound_title', + 'inbound_description', + 'inbound_link', + 'inbound_restricted_countries', + 'terms_and_conditions', + '_flags', + '_state', + ) + + def __init__(self, *, data: Union[PromotionPayload, ClaimedPromotionPayload], state: ConnectionState) -> None: + self._state = state + self._update(data) + + def __str__(self) -> str: + return self.outbound_title + + def __repr__(self) -> str: + return f'' + + def _update(self, data: Union[PromotionPayload, ClaimedPromotionPayload]) -> None: + promotion: PromotionPayload = data.get('promotion', data) # type: ignore + + self.id: int = int(promotion['id']) + self.trial_id: Optional[int] = _get_as_snowflake(promotion, 'trial_id') + self.starts_at: datetime = parse_time(promotion['start_date']) + self.ends_at: datetime = parse_time(promotion['end_date']) + self.claimed_at: Optional[datetime] = parse_time(data.get('claimed_at')) + self.code: Optional[str] = data.get('code') + self._flags: int = promotion.get('flags', 0) + + self.outbound_title: str = promotion['outbound_title'] + self.outbound_description: str = promotion['outbound_redemption_modal_body'] + self.outbound_link: str = promotion.get( + 'outbound_redemption_page_link', + promotion.get('outbound_redemption_url_format', '').replace('{code}', self.code or '{code}'), + ) + self.outbound_restricted_countries: List[str] = promotion.get('outbound_restricted_countries', []) + self.inbound_title: Optional[str] = promotion.get('inbound_header_text') + self.inbound_description: Optional[str] = promotion.get('inbound_body_text') + self.inbound_link: Optional[str] = promotion.get('inbound_help_center_link') + self.inbound_restricted_countries: List[str] = promotion.get('inbound_restricted_countries', []) + self.terms_and_conditions: str = promotion['outbound_terms_and_conditions'] + + @property + def flags(self) -> PromotionFlags: + """:class:`PromotionFlags`: Returns the promotion's flags.""" + return PromotionFlags._from_value(self._flags) + + def is_claimed(self) -> bool: + """:class:`bool`: Checks if the promotion has been claimed. + + Only accurate if the promotion was fetched from :meth:`Client.promotions` with ``claimed`` set to ``True`` or :meth:`claim` was just called. + """ + return self.claimed_at is not None + + def is_active(self) -> bool: + """:class:`bool`: Checks if the promotion is active.""" + return self.starts_at <= utcnow() <= self.ends_at + + async def claim(self) -> str: + """|coro| + + Claims the promotion. + + Sets :attr:`claimed_at` and :attr:`code`. + + Raises + ------ + Forbidden + You are not allowed to claim the promotion. + HTTPException + Claiming the promotion failed. + + Returns + ------- + :class:`str` + The claim code for the outbound promotion. + """ + data = await self._state.http.claim_promotion(self.id) + self._update(data) + return data['code'] + + +class TrialOffer(Hashable): + """Represents a Discord user trial offer. + + .. container:: operations + + .. describe:: x == y + + Checks if two trial offers are equal. + + .. describe:: x != y + + Checks if two trial offers are not equal. + + .. describe:: hash(x) + + Returns the trial offer's hash. + + .. versionadded:: 2.0 + + Attributes + ---------- + id: :class:`int` + The ID of the trial offer. + expires_at: :class:`datetime.datetime` + When the trial offer expires. + trial_id: :class:`int` + The ID of the trial. + trial: :class:`SubscriptionTrial` + The trial offered. + """ + + __slots__ = ( + 'id', + 'expires_at', + 'trial_id', + 'trial', + '_state', + ) + + def __init__(self, *, data: TrialOfferPayload, state: ConnectionState) -> None: + self._state = state + + self.id: int = int(data['id']) + self.expires_at: datetime = parse_time(data['expires_at']) + self.trial_id: int = int(data['trial_id']) + self.trial: SubscriptionTrial = SubscriptionTrial(data['subscription_trial']) + + def __repr__(self) -> str: + return f'' + + async def ack(self) -> None: + """|coro| + + Acknowledges the trial offer. + + Raises + ------ + HTTPException + Acknowledging the trial offer failed. + """ + await self._state.http.ack_trial_offer(self.id) + + +class PricingPromotion: + """Represents a Discord localized pricing promotion. + + .. versionadded:: 2.0 + + Attributes + ---------- + subscription_plan_id: :class:`int` + The ID of the subscription plan the promotion is for. + country_code: :class:`str` + The country code the promotion applies to. + payment_source_types: List[:class:`PaymentSourceType`] + The payment source types the promotion is restricted to. + amount: :class:`int` + The discounted price of the subscription plan. + currency: :class:`str` + The currency of the discounted price. + """ + + __slots__ = ( + 'subscription_plan_id', + 'country_code', + 'payment_source_types', + 'amount', + 'currency', + ) + + def __init__(self, *, data: PricingPromotionPayload) -> None: + self.subscription_plan_id: int = int(data['plan_id']) + self.country_code: str = data['country_code'] + self.payment_source_types: List[PaymentSourceType] = [ + try_enum(PaymentSourceType, t) for t in data['payment_source_types'] + ] + + price = data['price'] + self.amount: int = price['amount'] + self.currency: str = price['currency'] + + def __repr__(self) -> str: + return f'' diff --git a/discord/role.py b/discord/role.py index 9cae4f524..9cf9b66c1 100644 --- a/discord/role.py +++ b/discord/role.py @@ -460,7 +460,7 @@ class Role(Hashable): data = await state.http.add_members_to_role(guild.id, self.id, [m.id for m in members], reason=reason) return [Member(data=m, state=state, guild=guild) for m in data.values()] - async def remove_roles(self, *members: Snowflake, reason: Optional[str] = None) -> None: + async def remove_members(self, *members: Snowflake, reason: Optional[str] = None) -> None: r"""|coro| Removes :class:`Member`\s from this role. diff --git a/discord/state.py b/discord/state.py index b23f20622..95438a279 100644 --- a/discord/state.py +++ b/discord/state.py @@ -62,7 +62,7 @@ from .raw_models import * from .member import Member from .relationship import Relationship from .role import Role -from .enums import ChannelType, RequiredActionType, Status, try_enum, UnavailableGuildType +from .enums import ChannelType, PaymentSourceType, RequiredActionType, Status, try_enum, UnavailableGuildType from . import utils from .flags import MemberCacheFlags from .invite import Invite @@ -77,8 +77,12 @@ from .permissions import Permissions, PermissionOverwrite from .member import _ClientStatus from .modal import Modal from .member import VoiceState -from .appinfo import InteractionApplication +from .appinfo import IntegrationApplication, PartialApplication, Achievement from .connections import Connection +from .payments import Payment +from .entitlements import Entitlement, Gift +from .guild_premium import PremiumGuildSubscriptionSlot +from .library import LibraryApplication if TYPE_CHECKING: from .abc import PrivateChannel, Snowflake as abcSnowflake @@ -92,6 +96,7 @@ if TYPE_CHECKING: from .types.snowflake import Snowflake from .types.activity import Activity as ActivityPayload + from .types.appinfo import Achievement as AchievementPayload, IntegrationApplication as IntegrationApplicationPayload from .types.channel import DMChannel as DMChannelPayload from .types.user import User as UserPayload, PartialUser as PartialUserPayload from .types.emoji import Emoji as EmojiPayload, PartialEmoji as PartialEmojiPayload @@ -462,10 +467,12 @@ class ConnectionState: self.guild_settings: Dict[Optional[int], GuildSettings] = {} self.consents: Optional[TrackingSettings] = None self.connections: Dict[str, Connection] = {} + self.pending_payments: Dict[int, Payment] = {} self.analytics_token: Optional[str] = None self.preferred_regions: List[str] = [] self.country_code: Optional[str] = None self.session_type: Optional[str] = None + self.auth_session_id: Optional[str] = None self._emojis: Dict[int, Emoji] = {} self._stickers: Dict[int, GuildSticker] = {} self._guilds: Dict[int, Guild] = {} @@ -533,6 +540,10 @@ class ConnectionState: u = self.user return u.id if u else None + @property + def locale(self) -> str: + return str(getattr(self.user, 'locale', 'en-US')) + @property def preferred_region(self) -> str: return self.preferred_regions[0] if self.preferred_regions else 'us-central' @@ -607,6 +618,9 @@ class ConnectionState: return user def create_user(self, data: Union[UserPayload, PartialUserPayload]) -> User: + user_id = int(data['id']) + if user_id == self.self_id: + return self.user # type: ignore return User(state=self, data=data) def get_user(self, id: int) -> Optional[User]: @@ -819,7 +833,7 @@ class ConnectionState: data = self._ready_data # Temp user parsing - temp_users: Dict[int, UserPayload] = {int(data['user']['id']): data['user']} + temp_users: Dict[int, PartialUserPayload] = {int(data['user']['id']): data['user']} for u in data.get('users', []): u_id = int(u['id']) temp_users[u_id] = u @@ -893,7 +907,9 @@ class ConnectionState: self.consents = TrackingSettings(data=data.get('consents', {}), state=self) self.country_code = data.get('country_code', 'US') self.session_type = data.get('session_type', 'normal') + self.auth_session_id = data.get('auth_session_id_hash') self.connections = {c['id']: Connection(state=self, data=c) for c in data.get('connected_accounts', [])} + self.pending_payments = {int(p['id']): Payment(state=self, data=p) for p in data.get('pending_payments', [])} if 'required_action' in data: self.parse_user_required_action_update(data) @@ -1054,7 +1070,7 @@ class ConnectionState: member_id = int(user['id']) member = guild.get_member(member_id) if member is None: - _log.debug('PRESENCE_UPDATE referencing an unknown member ID: %s. Discarding', member_id) + _log.debug('PRESENCE_UPDATE referencing an unknown member ID: %s. Discarding.', member_id) return old_member = Member._copy(member) @@ -1088,12 +1104,17 @@ class ConnectionState: settings = GuildSettings(data=data, state=self) self.dispatch('guild_settings_update', old_settings, settings) - def parse_user_required_action_update(self, data) -> None: - required_action = try_enum(RequiredActionType, data['required_action']) + def parse_user_required_action_update(self, data: Union[gw.RequiredActionEvent, gw.ReadyEvent]) -> None: + required_action = try_enum(RequiredActionType, data['required_action']) # type: ignore self.dispatch('required_action_update', required_action) - def parse_user_connections_update(self, data: gw.Connection) -> None: - id = data['id'] + def parse_user_connections_update(self, data: Union[gw.ConnectionEvent, gw.PartialConnectionEvent]) -> None: + self.dispatch('connections_update') + + id = data.get('id') + if id is None or 'user_id' in data: + return + if id not in self.connections: self.connections[id] = connection = Connection(state=self, data=data) self.dispatch('connection_create', connection) @@ -1107,6 +1128,69 @@ class ConnectionState: connection._update(data) self.dispatch('connection_update', old_connection, connection) + def parse_user_connections_link_callback(self, data: gw.ConnectionsLinkCallbackEvent) -> None: + self.dispatch('connections_link_callback', data['provider'], data['callback_code'], data['callback_state']) + + def parse_user_payment_sources_update(self, data: gw.NoEvent) -> None: + self.dispatch('payment_sources_update') + + def parse_user_subscriptions_update(self, data: gw.NoEvent) -> None: + self.dispatch('subscriptions_update') + + def parse_user_payment_client_add(self, data: gw.PaymentClientAddEvent) -> None: + self.dispatch('payment_client_add', data['purchase_token_hash'], utils.parse_time(data['expires_at'])) + + def parse_user_premium_guild_subscription_slot_create(self, data: gw.PremiumGuildSubscriptionSlotEvent) -> None: + slot = PremiumGuildSubscriptionSlot(state=self, data=data) + self.dispatch('premium_guild_subscription_slot_create', slot) + + def parse_user_premium_guild_subscription_slot_update(self, data: gw.PremiumGuildSubscriptionSlotEvent) -> None: + slot = PremiumGuildSubscriptionSlot(state=self, data=data) + self.dispatch('premium_guild_subscription_slot_update', slot) + + def parse_user_achievement_update(self, data: gw.AchievementUpdatePayload) -> None: + achievement: AchievementPayload = data.get('achievement') # type: ignore + application_id = data.get('application_id') + if not achievement or not application_id: + _log.warning('USER_ACHIEVEMENT_UPDATE payload has invalid data: %s. Discarding.', list(data.keys())) + return + + achievement['application_id'] = application_id + model = Achievement(state=self, data=achievement) + self.dispatch('achievement_update', model, data.get('percent_complete', 0)) + + def parse_billing_popup_bridge_callback(self, data: gw.BillingPopupBridgeCallbackEvent) -> None: + self.dispatch( + 'billing_popup_bridge_callback', + try_enum(PaymentSourceType, data.get('payment_source_type', 0)), + data.get('path'), + data.get('query'), + data.get('state'), + ) + + def parse_oauth2_token_revoke(self, data: gw.OAuth2TokenRevokeEvent) -> None: + if 'access_token' not in data: + _log.warning('OAUTH2_TOKEN_REVOKE payload has invalid data: %s. Discarding.', list(data.keys())) + self.dispatch('oauth2_token_revoke', data['access_token']) + + def parse_auth_session_change(self, data: gw.AuthSessionChangeEvent) -> None: + self.auth_session_id = auth_session_id = data['auth_session_id_hash'] + self.dispatch('auth_session_change', auth_session_id) + + def parse_payment_update(self, data: gw.PaymentUpdateEvent) -> None: + id = int(data['id']) + payment = self.pending_payments.get(id) + if payment is not None: + payment._update(data) + else: + payment = Payment(state=self, data=data) + + self.dispatch('payment_update', payment) + + def parse_library_application_update(self, data: gw.LibraryApplicationUpdateEvent) -> None: + entry = LibraryApplication(state=self, data=data) + self.dispatch('library_application_update', entry) + def parse_sessions_replace(self, data: List[Dict[str, Any]]) -> None: overall = MISSING this = MISSING @@ -1148,6 +1232,28 @@ class ConnectionState: client._client_activities = client_activities client._session_count = len(data) + def parse_entitlement_create(self, data: gw.EntitlementEvent) -> None: + entitlement = Entitlement(state=self, data=data) + self.dispatch('entitlement_create', entitlement) + + def parse_entitlement_update(self, data: gw.EntitlementEvent) -> None: + entitlement = Entitlement(state=self, data=data) + self.dispatch('entitlement_update', entitlement) + + def parse_entitlement_delete(self, data: gw.EntitlementEvent) -> None: + entitlement = Entitlement(state=self, data=data) + self.dispatch('entitlement_delete', entitlement) + + def parse_gift_code_create(self, data: gw.GiftCreateEvent) -> None: + # Should be fine:tm: + gift = Gift(state=self, data=data) # type: ignore + self.dispatch('gift_create', gift) + + def parse_gift_code_update(self, data: gw.GiftUpdateEvent) -> None: + # Should be fine:tm: + gift = Gift(state=self, data=data) # type: ignore + self.dispatch('gift_update', gift) + def parse_invite_create(self, data: gw.InviteCreateEvent) -> None: invite = Invite.from_gateway(state=self, data=data) self.dispatch('invite_create', invite) @@ -1642,6 +1748,8 @@ class ConnectionState: guild.command_counts = CommandCounts(data.get(0, 0), data.get(1, 0), data.get(2, 0)) + parse_guild_application_command_index_update = parse_guild_application_command_counts_update + def parse_guild_emojis_update(self, data: gw.GuildEmojisUpdateEvent) -> None: guild = self._get_guild(int(data['guild_id'])) if guild is None: @@ -1670,7 +1778,7 @@ class ConnectionState: def _get_create_guild(self, data: gw.GuildCreateEvent): guild = self._get_guild(int(data['id'])) - # Discord being Discord sends a GUILD_CREATE after an OPCode 14 is sent (a la bots) + # Discord being Discord sometimes sends a GUILD_CREATE after an OPCode 14 is sent (a la bots) # However, we want that if we forced a GUILD_CREATE for an unavailable guild if guild is not None: guild._from_data(data) @@ -2310,11 +2418,37 @@ class ConnectionState: def create_message(self, *, channel: MessageableChannel, data: MessagePayload) -> Message: return Message(state=self, channel=channel, data=data) - def create_interaction_application(self, data: dict) -> InteractionApplication: - return InteractionApplication(state=self, data=data) + def create_integration_application(self, data: IntegrationApplicationPayload) -> IntegrationApplication: + return IntegrationApplication(state=self, data=data) def default_guild_settings(self, guild_id: Optional[int]) -> GuildSettings: return GuildSettings(data={'guild_id': guild_id}, state=self) def default_channel_settings(self, guild_id: Optional[int], channel_id: int) -> ChannelSettings: return ChannelSettings(guild_id, data={'channel_id': channel_id}, state=self) + + @utils.cached_property + def premium_subscriptions_application(self) -> PartialApplication: + # Hardcoded application for premium subscriptions, highly unlikely to change + return PartialApplication( + state=self, + data={ + 'id': 521842831262875670, + 'name': 'Nitro', + 'icon': None, + 'description': '', + 'verify_key': '93661a9eefe452d12f51e129e8d9340e7ca53a770158c0ec7970e701534b7420', + 'type': None, + }, + ) + + @utils.cached_property + def premium_subscriptions_sku_ids(self) -> Dict[str, Snowflake]: + return { + 'none': 628379670982688768, + 'basic': 978380684370378762, + 'legacy': 521842865731534868, + 'classic': 521846918637420545, + 'full': 521847234246082599, + 'guild': 590663762298667008, + } diff --git a/discord/store.py b/discord/store.py new file mode 100644 index 000000000..297ced6eb --- /dev/null +++ b/discord/store.py @@ -0,0 +1,2281 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Dolfies + +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 datetime import datetime, timedelta +from typing import TYPE_CHECKING, Any, Collection, Dict, List, Mapping, Optional, Sequence, Tuple, Union + +from .asset import Asset, AssetMixin +from .enums import ( + ContentRatingAgency, + ESRBContentDescriptor, + ESRBRating, + GiftStyle, + Locale, + OperatingSystem, + PEGIContentDescriptor, + PEGIRating, + PremiumType, + SKUAccessLevel, + SKUFeature, + SKUGenre, + SKUType, + SubscriptionInterval, + SubscriptionPlanPurchaseType, + try_enum, +) +from .flags import SKUFlags +from .mixins import Hashable +from .utils import ( + MISSING, + _get_as_snowflake, + _get_extension_for_mime_type, + _parse_localizations, + get, + parse_date, + parse_time, + utcnow, +) + +if TYPE_CHECKING: + from datetime import date + from typing_extensions import Self + + from .abc import Snowflake + from .appinfo import Application, PartialApplication + from .entitlements import Entitlement, Gift, GiftBatch + from .guild import Guild + from .library import LibraryApplication + from .state import ConnectionState + from .types.appinfo import StoreAsset as StoreAssetPayload + from .types.entitlements import Gift as GiftPayload + from .types.snowflake import Snowflake as SnowflakeType + from .types.store import ( + SKU as SKUPayload, + CarouselItem as CarouselItemPayload, + ContentRating as ContentRatingPayload, + SKUPrice as SKUPricePayload, + StoreListing as StoreListingPayload, + StoreNote as StoreNotePayload, + SystemRequirements as SystemRequirementsPayload, + ) + from .types.subscriptions import ( + PartialSubscriptionPlan as PartialSubscriptionPlanPayload, + SubscriptionPlan as SubscriptionPlanPayload, + SubscriptionPrice as SubscriptionPricePayload, + SubscriptionPrices as SubscriptionPricesPayload, + ) + from .user import User + +__all__ = ( + 'StoreAsset', + 'StoreNote', + 'SystemRequirements', + 'StoreListing', + 'SKUPrice', + 'ContentRating', + 'SKU', + 'SubscriptionPlanPrices', + 'SubscriptionPlan', +) + +THE_GAME_AWARDS_WINNERS = (500428425362931713, 451550535720501248, 471376328319303681, 466696214818193408) + + +class StoreAsset(AssetMixin, Hashable): + """Represents an application store asset. + + .. container:: operations + + .. describe:: x == y + + Checks if two assets are equal. + + .. describe:: x != y + + Checks if two assets are not equal. + + .. describe:: hash(x) + + Returns the asset's hash. + + .. versionadded:: 2.0 + + Attributes + ----------- + parent: Union[:class:`StoreListing`, :class:`Application`] + The store listing or application that this asset belongs to. + id: Union[:class:`int`, :class:`str`] + The asset's ID or YouTube video ID. + size: :class:`int` + The asset's size in bytes, or 0 if it's a YouTube video. + height: :class:`int` + The asset's height in pixels, or 0 if it's a YouTube video. + width: :class:`int` + The asset's width in pixels, or 0 if it's a YouTube video. + mime_type: :class:`str` + The asset's mime type, or "video/youtube" if it is a YouTube video. + """ + + __slots__ = ('_state', 'parent', 'id', 'size', 'height', 'width', 'mime_type') + + def __init__(self, *, data: StoreAssetPayload, state: ConnectionState, parent: Union[StoreListing, Application]) -> None: + self._state: ConnectionState = state + self.parent = parent + self.size: int = data['size'] + self.height: int = data['height'] + self.width: int = data['width'] + self.mime_type: str = data['mime_type'] + + self.id: SnowflakeType + try: + self.id = int(data['id']) + except ValueError: + self.id = data['id'] + + @classmethod + def _from_id( + cls, *, id: SnowflakeType, mime_type: str = '', state: ConnectionState, parent: Union[StoreListing, Application] + ) -> StoreAsset: + data: StoreAssetPayload = {'id': id, 'size': 0, 'height': 0, 'width': 0, 'mime_type': mime_type} + return cls(data=data, state=state, parent=parent) + + @classmethod + def _from_carousel_item( + cls, *, data: CarouselItemPayload, state: ConnectionState, store_listing: StoreListing + ) -> StoreAsset: + asset_id = _get_as_snowflake(data, 'asset_id') + if asset_id: + return get(store_listing.assets, id=asset_id) or StoreAsset._from_id( + id=asset_id, state=state, parent=store_listing + ) + else: + # One or the other must be present + return cls._from_id(id=data['youtube_video_id'], mime_type='video/youtube', state=state, parent=store_listing) # type: ignore + + def __repr__(self) -> str: + return f'' + + @property + def application_id(self) -> int: + """:class:`int`: Returns the application ID that this asset belongs to.""" + parent = self.parent + return parent.sku.application_id if hasattr(parent, 'sku') else parent.id # type: ignore # Type checker doesn't understand + + @property + def animated(self) -> bool: + """:class:`bool`: Indicates if the store asset is animated.""" + return self.mime_type in {'video/youtube', 'image/gif', 'video/mp4'} + + @property + def url(self) -> str: + """:class:`str`: Returns the URL of the store asset.""" + if self.is_youtube_video(): + return f'https://youtube.com/watch?v={self.id}' + return ( + f'{Asset.BASE}/app-assets/{self.application_id}/store/{self.id}.{_get_extension_for_mime_type(self.mime_type)}' + ) + + def is_youtube_video(self) -> bool: + """:class:`bool`: Indicates if the asset is a YouTube video.""" + return self.mime_type == 'video/youtube' + + def to_carousel_item(self) -> dict: + if self.is_youtube_video(): + return {'youtube_video_id': self.id} + return {'asset_id': self.id} + + async def read(self) -> bytes: + """|coro| + + Retrieves the content of this asset as a :class:`bytes` object. + + Raises + ------ + ValueError + The asset is a YouTube video. + HTTPException + Downloading the asset failed. + NotFound + The asset was deleted. + + Returns + ------- + :class:`bytes` + The content of the asset. + """ + if self.is_youtube_video(): + raise ValueError('StoreAsset is not a real asset') + + return await super().read() + + async def delete(self) -> None: + """|coro| + + Deletes the asset. + + Raises + ------ + ValueError + The asset is a YouTube video. + Forbidden + You are not allowed to delete this asset. + HTTPException + Deleting the asset failed. + """ + if self.is_youtube_video(): + raise ValueError('StoreAsset is not a real asset') + + await self._state.http.delete_store_asset(self.application_id, self.id) + + +class StoreNote: + """Represents a note for a store listing. + + .. container:: operations + + .. describe:: str(x) + + Returns the note's content. + + .. versionadded:: 2.0 + + Attributes + ----------- + user: Optional[:class:`User`] + The user who wrote the note. + content: :class:`str` + The note content. + """ + + __slots__ = ('user', 'content') + + def __init__(self, *, data: StoreNotePayload, state: ConnectionState) -> None: + self.user: Optional[User] = state.create_user(data['user']) if data.get('user') else None # type: ignore + self.content: str = data['content'] + + def __repr__(self) -> str: + return f'' + + def __str__(self) -> str: + return self.content + + +class SystemRequirements: + """Represents system requirements. + + .. versionadded:: 2.0 + + Attributes + ----------- + os: :class:`OperatingSystem` + The operating system these requirements apply to. + minimum_os_version: :class:`str` + The minimum operating system version required. + recommended_os_version: :class:`str` + The recommended operating system version. + minimum_cpu: :class:`str` + The minimum CPU specifications required. + recommended_cpu: :class:`str` + The recommended CPU specifications. + minimum_gpu: :class:`str` + The minimum GPU specifications required. + recommended_gpu: :class:`str` + The recommended GPU specifications. + minimum_ram: :class:`int` + The minimum RAM size in megabytes. + recommended_ram: :class:`int` + The recommended RAM size in megabytes. + minimum_disk: :class:`int` + The minimum free storage space in megabytes. + recommended_disk: :class:`int` + The recommended free storage space in megabytes. + minimum_sound_card: Optional[:class:`str`] + The minimum sound card specifications required, if any. + recommended_sound_card: Optional[:class:`str`] + The recommended sound card specifications, if any. + minimum_directx: Optional[:class:`str`] + The minimum DirectX version required, if any. + recommended_directx: Optional[:class:`str`] + The recommended DirectX version, if any. + minimum_network: Optional[:class:`str`] + The minimum network specifications required, if any. + recommended_network: Optional[:class:`str`] + The recommended network specifications, if any. + minimum_notes: Optional[:class:`str`] + Any extra notes on minimum requirements. + recommended_notes: Optional[:class:`str`] + Any extra notes on recommended requirements. + """ + + if TYPE_CHECKING: + os: OperatingSystem + minimum_ram: Optional[int] + recommended_ram: Optional[int] + minimum_disk: Optional[int] + recommended_disk: Optional[int] + minimum_os_version: Optional[str] + minimum_os_version_localizations: Dict[Locale, str] + recommended_os_version: Optional[str] + recommended_os_version_localizations: Dict[Locale, str] + minimum_cpu: Optional[str] + minimum_cpu_localizations: Dict[Locale, str] + recommended_cpu: Optional[str] + recommended_cpu_localizations: Dict[Locale, str] + minimum_gpu: Optional[str] + minimum_gpu_localizations: Dict[Locale, str] + recommended_gpu: Optional[str] + recommended_gpu_localizations: Dict[Locale, str] + minimum_sound_card: Optional[str] + minimum_sound_card_localizations: Dict[Locale, str] + recommended_sound_card: Optional[str] + recommended_sound_card_localizations: Dict[Locale, str] + minimum_directx: Optional[str] + minimum_directx_localizations: Dict[Locale, str] + recommended_directx: Optional[str] + recommended_directx_localizations: Dict[Locale, str] + minimum_network: Optional[str] + minimum_network_localizations: Dict[Locale, str] + recommended_network: Optional[str] + recommended_network_localizations: Dict[Locale, str] + minimum_notes: Optional[str] + minimum_notes_localizations: Dict[Locale, str] + recommended_notes: Optional[str] + recommended_notes_localizations: Dict[Locale, str] + + __slots__ = ( + 'os', + 'minimum_ram', + 'recommended_ram', + 'minimum_disk', + 'recommended_disk', + 'minimum_os_version', + 'minimum_os_version_localizations', + 'recommended_os_version', + 'recommended_os_version_localizations', + 'minimum_cpu', + 'minimum_cpu_localizations', + 'recommended_cpu', + 'recommended_cpu_localizations', + 'minimum_gpu', + 'minimum_gpu_localizations', + 'recommended_gpu', + 'recommended_gpu_localizations', + 'minimum_sound_card', + 'minimum_sound_card_localizations', + 'recommended_sound_card', + 'recommended_sound_card_localizations', + 'minimum_directx', + 'minimum_directx_localizations', + 'recommended_directx', + 'recommended_directx_localizations', + 'minimum_network', + 'minimum_network_localizations', + 'recommended_network', + 'recommended_network_localizations', + 'minimum_notes', + 'minimum_notes_localizations', + 'recommended_notes', + 'recommended_notes_localizations', + ) + + def __init__( + self, + os: OperatingSystem, + *, + minimum_ram: Optional[int] = None, + recommended_ram: Optional[int] = None, + minimum_disk: Optional[int] = None, + recommended_disk: Optional[int] = None, + minimum_os_version: Optional[str] = None, + minimum_os_version_localizations: Optional[Dict[Locale, str]] = None, + recommended_os_version: Optional[str] = None, + recommended_os_version_localizations: Optional[Dict[Locale, str]] = None, + minimum_cpu: Optional[str] = None, + minimum_cpu_localizations: Optional[Dict[Locale, str]] = None, + recommended_cpu: Optional[str] = None, + recommended_cpu_localizations: Optional[Dict[Locale, str]] = None, + minimum_gpu: Optional[str] = None, + minimum_gpu_localizations: Optional[Dict[Locale, str]] = None, + recommended_gpu: Optional[str] = None, + recommended_gpu_localizations: Optional[Dict[Locale, str]] = None, + minimum_sound_card: Optional[str] = None, + minimum_sound_card_localizations: Optional[Dict[Locale, str]] = None, + recommended_sound_card: Optional[str] = None, + recommended_sound_card_localizations: Optional[Dict[Locale, str]] = None, + minimum_directx: Optional[str] = None, + minimum_directx_localizations: Optional[Dict[Locale, str]] = None, + recommended_directx: Optional[str] = None, + recommended_directx_localizations: Optional[Dict[Locale, str]] = None, + minimum_network: Optional[str] = None, + minimum_network_localizations: Optional[Dict[Locale, str]] = None, + recommended_network: Optional[str] = None, + recommended_network_localizations: Optional[Dict[Locale, str]] = None, + minimum_notes: Optional[str] = None, + minimum_notes_localizations: Optional[Dict[Locale, str]] = None, + recommended_notes: Optional[str] = None, + recommended_notes_localizations: Optional[Dict[Locale, str]] = None, + ) -> None: + self.os = os + self.minimum_ram = minimum_ram + self.recommended_ram = recommended_ram + self.minimum_disk = minimum_disk + self.recommended_disk = recommended_disk + self.minimum_os_version = minimum_os_version + self.minimum_os_version_localizations = minimum_os_version_localizations or {} + self.recommended_os_version = recommended_os_version + self.recommended_os_version_localizations = recommended_os_version_localizations or {} + self.minimum_cpu = minimum_cpu + self.minimum_cpu_localizations = minimum_cpu_localizations or {} + self.recommended_cpu = recommended_cpu + self.recommended_cpu_localizations = recommended_cpu_localizations or {} + self.minimum_gpu = minimum_gpu + self.minimum_gpu_localizations = minimum_gpu_localizations or {} + self.recommended_gpu = recommended_gpu + self.recommended_gpu_localizations = recommended_gpu_localizations or {} + self.minimum_sound_card = minimum_sound_card + self.minimum_sound_card_localizations = minimum_sound_card_localizations or {} + self.recommended_sound_card = recommended_sound_card + self.recommended_sound_card_localizations = recommended_sound_card_localizations or {} + self.minimum_directx = minimum_directx + self.minimum_directx_localizations = minimum_directx_localizations or {} + self.recommended_directx = recommended_directx + self.recommended_directx_localizations = recommended_directx_localizations or {} + self.minimum_network = minimum_network + self.minimum_network_localizations = minimum_network_localizations or {} + self.recommended_network = recommended_network + self.recommended_network_localizations = recommended_network_localizations or {} + self.minimum_notes = minimum_notes + self.minimum_notes_localizations = minimum_notes_localizations or {} + self.recommended_notes = recommended_notes + self.recommended_notes_localizations = recommended_notes_localizations or {} + + @classmethod + def from_dict(cls, os: OperatingSystem, data: SystemRequirementsPayload) -> Self: + minimum = data.get('minimum', {}) + recommended = data.get('recommended', {}) + + minimum_os_version, minimum_os_version_localizations = _parse_localizations(minimum, 'operating_system_version') + recommended_os_version, recommended_os_version_localizations = _parse_localizations( + recommended, 'operating_system_version' + ) + minimum_cpu, minimum_cpu_localizations = _parse_localizations(minimum, 'cpu') + recommended_cpu, recommended_cpu_localizations = _parse_localizations(recommended, 'cpu') + minimum_gpu, minimum_gpu_localizations = _parse_localizations(minimum, 'gpu') + recommended_gpu, recommended_gpu_localizations = _parse_localizations(recommended, 'gpu') + minimum_sound_card, minimum_sound_card_localizations = _parse_localizations(minimum, 'sound_card') + recommended_sound_card, recommended_sound_card_localizations = _parse_localizations(recommended, 'sound_card') + minimum_directx, minimum_directx_localizations = _parse_localizations(minimum, 'directx') + recommended_directx, recommended_directx_localizations = _parse_localizations(recommended, 'directx') + minimum_network, minimum_network_localizations = _parse_localizations(minimum, 'network') + recommended_network, recommended_network_localizations = _parse_localizations(recommended, 'network') + minimum_notes, minimum_notes_localizations = _parse_localizations(minimum, 'notes') + recommended_notes, recommended_notes_localizations = _parse_localizations(recommended, 'notes') + + return cls( + os, + minimum_ram=minimum.get('ram'), + recommended_ram=recommended.get('ram'), + minimum_disk=minimum.get('disk'), + recommended_disk=recommended.get('disk'), + minimum_os_version=minimum_os_version, + minimum_os_version_localizations=minimum_os_version_localizations, + recommended_os_version=recommended_os_version, + recommended_os_version_localizations=recommended_os_version_localizations, + minimum_cpu=minimum_cpu, + minimum_cpu_localizations=minimum_cpu_localizations, + recommended_cpu=recommended_cpu, + recommended_cpu_localizations=recommended_cpu_localizations, + minimum_gpu=minimum_gpu, + minimum_gpu_localizations=minimum_gpu_localizations, + recommended_gpu=recommended_gpu, + recommended_gpu_localizations=recommended_gpu_localizations, + minimum_sound_card=minimum_sound_card, + minimum_sound_card_localizations=minimum_sound_card_localizations, + recommended_sound_card=recommended_sound_card, + recommended_sound_card_localizations=recommended_sound_card_localizations, + minimum_directx=minimum_directx, + minimum_directx_localizations=minimum_directx_localizations, + recommended_directx=recommended_directx, + recommended_directx_localizations=recommended_directx_localizations, + minimum_network=minimum_network, + minimum_network_localizations=minimum_network_localizations, + recommended_network=recommended_network, + recommended_network_localizations=recommended_network_localizations, + minimum_notes=minimum_notes, + minimum_notes_localizations=minimum_notes_localizations, + recommended_notes=recommended_notes, + recommended_notes_localizations=recommended_notes_localizations, + ) + + def __repr__(self) -> str: + return f'' + + def to_dict(self) -> dict: + minimum = {} + recommended = {} + for key in self.__slots__: + if key.endswith('_localizations'): + continue + + value = getattr(self, key) + localizations = getattr(self, f'{key}_localizations', None) + if value or localizations: + data = ( + value + if localizations is None + else {'default': value, 'localizations': {str(k): v for k, v in localizations.items()}} + ) + if key.startswith('minimum_'): + minimum[key[8:]] = data + elif key.startswith('recommended_'): + recommended[key[12:]] = data + + return {'minimum': minimum, 'recommended': recommended} + + +class StoreListing(Hashable): + """Represents a store listing. + + .. container:: operations + + .. describe:: x == y + + Checks if two listings are equal. + + .. describe:: x != y + + Checks if two listings are not equal. + + .. describe:: hash(x) + + Returns the listing's hash. + + .. describe:: str(x) + + Returns the listing's summary. + + .. versionadded:: 2.0 + + Attributes + ----------- + id: :class:`int` + The listing's ID. + summary: Optional[:class:`str`] + The listing's summary. + summary_localizations: Dict[:class:`Locale`, :class:`str`] + The listing's summary localized to different languages. + description: Optional[:class:`str`] + The listing's description. + description_localizations: Dict[:class:`Locale`, :class:`str`] + The listing's description localized to different languages. + tagline: Optional[:class:`str`] + The listing's tagline. + tagline_localizations: Dict[:class:`Locale`, :class:`str`] + The listing's tagline localized to different languages. + flavor: Optional[:class:`str`] + The listing's flavor text. + sku: :class:`SKU` + The SKU attached to this listing. + child_skus: List[:class:`SKU`] + The child SKUs attached to this listing. + alternative_skus: List[:class:`SKU`] + Alternative SKUs to the one attached to this listing. + guild: Optional[:class:`Guild`] + The guild tied to this listing, if any. + published: :class:`bool` + Whether the listing is published and publicly visible. + staff_note: Optional[:class:`StoreNote`] + The staff note attached to this listing. + assets: List[:class:`StoreAsset`] + A list of assets used in this listing. + carousel_items: List[:class:`StoreAsset`] + A list of assets and YouTube videos displayed in the carousel. + preview_video: Optional[:class:`StoreAsset`] + The preview video of the store listing. + header_background: Optional[:class:`StoreAsset`] + The header background image. + hero_background: Optional[:class:`StoreAsset`] + The hero background image. + box_art: Optional[:class:`StoreAsset`] + The box art of the product. + thumbnail: Optional[:class:`StoreAsset`] + The listing's thumbnail. + header_logo_light: Optional[:class:`StoreAsset`] + The header logo image for light backgrounds. + header_logo_dark: Optional[:class:`StoreAsset`] + The header logo image for dark backgrounds. + """ + + __slots__ = ( + '_state', + 'id', + 'summary', + 'summary_localizations', + 'description', + 'description_localizations', + 'tagline', + 'tagline_localizations', + 'flavor', + 'sku', + 'child_skus', + 'alternative_skus', + 'entitlement_branch_id', + 'guild', + 'published', + 'staff_note', + 'assets', + 'carousel_items', + 'preview_video', + 'header_background', + 'hero_background', + 'hero_video', + 'box_art', + 'thumbnail', + 'header_logo_light', + 'header_logo_dark', + ) + + if TYPE_CHECKING: + summary: Optional[str] + summary_localizations: Dict[Locale, str] + description: Optional[str] + description_localizations: Dict[Locale, str] + tagline: Optional[str] + tagline_localizations: Dict[Locale, str] + + def __init__( + self, *, data: StoreListingPayload, state: ConnectionState, application: Optional[PartialApplication] = None + ) -> None: + self._state = state + self._update(data, application=application) + + def __str__(self) -> str: + return self.summary or '' + + def __repr__(self) -> str: + return f'' + + def _update(self, data: StoreListingPayload, application: Optional[PartialApplication] = None) -> None: + from .guild import Guild + + state = self._state + + self.summary, self.summary_localizations = _parse_localizations(data, 'summary') + self.description, self.description_localizations = _parse_localizations(data, 'description') + self.tagline, self.tagline_localizations = _parse_localizations(data, 'tagline') + + self.id: int = int(data['id']) + self.flavor: Optional[str] = data.get('flavor_text') + self.sku: SKU = SKU(data=data['sku'], state=state, application=application) + self.child_skus: List[SKU] = [SKU(data=sku, state=state) for sku in data.get('child_skus', [])] + self.alternative_skus: List[SKU] = [SKU(data=sku, state=state) for sku in data.get('alternative_skus', [])] + self.entitlement_branch_id: Optional[int] = _get_as_snowflake(data, 'entitlement_branch_id') + self.guild: Optional[Guild] = Guild(data=data['guild'], state=state) if 'guild' in data else None + self.published: bool = data.get('published', True) + self.staff_note: Optional[StoreNote] = ( + StoreNote(data=data['staff_notes'], state=state) if 'staff_notes' in data else None + ) + + self.assets: List[StoreAsset] = [ + StoreAsset(data=asset, state=state, parent=self) for asset in data.get('assets', []) + ] + self.carousel_items: List[StoreAsset] = [ + StoreAsset._from_carousel_item(data=asset, state=state, store_listing=self) + for asset in data.get('carousel_items', []) + ] + self.preview_video: Optional[StoreAsset] = ( + StoreAsset(data=data['preview_video'], state=state, parent=self) if 'preview_video' in data else None + ) + self.header_background: Optional[StoreAsset] = ( + StoreAsset(data=data['header_background'], state=state, parent=self) if 'header_background' in data else None + ) + self.hero_background: Optional[StoreAsset] = ( + StoreAsset(data=data['hero_background'], state=state, parent=self) if 'hero_background' in data else None + ) + self.hero_video: Optional[StoreAsset] = ( + StoreAsset(data=data['hero_video'], state=state, parent=self) if 'hero_video' in data else None + ) + self.box_art: Optional[StoreAsset] = ( + StoreAsset(data=data['box_art'], state=state, parent=self) if 'box_art' in data else None + ) + self.thumbnail: Optional[StoreAsset] = ( + StoreAsset(data=data['thumbnail'], state=state, parent=self) if 'thumbnail' in data else None + ) + self.header_logo_light: Optional[StoreAsset] = ( + StoreAsset(data=data['header_logo_light_theme'], state=state, parent=self) + if 'header_logo_light_theme' in data + else None + ) + self.header_logo_dark: Optional[StoreAsset] = ( + StoreAsset(data=data['header_logo_dark_theme'], state=state, parent=self) + if 'header_logo_dark_theme' in data + else None + ) + + async def edit( + self, + *, + summary: Optional[str] = MISSING, + summary_localizations: Mapping[Locale, str] = MISSING, + description: Optional[str] = MISSING, + description_localizations: Mapping[Locale, str] = MISSING, + tagline: Optional[str] = MISSING, + tagline_localizations: Mapping[Locale, str] = MISSING, + child_skus: Sequence[Snowflake] = MISSING, + guild: Optional[Snowflake] = MISSING, + published: bool = MISSING, + carousel_items: Sequence[Union[StoreAsset, str]] = MISSING, + preview_video: Optional[Snowflake] = MISSING, + header_background: Optional[Snowflake] = MISSING, + hero_background: Optional[Snowflake] = MISSING, + hero_video: Optional[Snowflake] = MISSING, + box_art: Optional[Snowflake] = MISSING, + thumbnail: Optional[Snowflake] = MISSING, + header_logo_light: Optional[Snowflake] = MISSING, + header_logo_dark: Optional[Snowflake] = MISSING, + ): + """|coro| + + Edits the store listing. + + All parameters are optional. + + Parameters + ---------- + summary: Optional[:class:`str`] + The summary of the store listing. + summary_localizations: Dict[:class:`Locale`, :class:`str`] + The summary of the store listing localized to different languages. + description: Optional[:class:`str`] + The description of the store listing. + description_localizations: Dict[:class:`Locale`, :class:`str`] + The description of the store listing localized to different languages. + tagline: Optional[:class:`str`] + The tagline of the store listing. + tagline_localizations: Dict[:class:`Locale`, :class:`str`] + The tagline of the store listing localized to different languages. + child_skus: List[:class:`SKU`] + The child SKUs of the store listing. + guild: Optional[:class:`Guild`] + The guild that the store listing is for. + published: :class:`bool` + Whether the store listing is published. + carousel_items: List[Union[:class:`StoreAsset`, :class:`str`]] + A list of carousel items to add to the store listing. These can be store assets or YouTube video IDs. + preview_video: Optional[:class:`StoreAsset`] + The preview video of the store listing. + header_background: Optional[:class:`StoreAsset`] + The header background of the store listing. + hero_background: Optional[:class:`StoreAsset`] + The hero background of the store listing. + hero_video: Optional[:class:`StoreAsset`] + The hero video of the store listing. + box_art: Optional[:class:`StoreAsset`] + The box art of the store listing. + thumbnail: Optional[:class:`StoreAsset`] + The thumbnail of the store listing. + header_logo_light: Optional[:class:`StoreAsset`] + The header logo image for light backgrounds. + header_logo_dark: Optional[:class:`StoreAsset`] + The header logo image for dark backgrounds. + + Raises + ------ + Forbidden + You do not have permissions to edit the store listing. + HTTPException + Editing the store listing failed. + """ + payload = {} + + if summary is not MISSING or summary_localizations is not MISSING: + localizations = ( + (summary_localizations or {}) if summary_localizations is not MISSING else self.summary_localizations + ) + payload['name'] = { + 'default': (summary if summary is not MISSING else self.summary) or '', + 'localizations': {str(k): v for k, v in localizations.items()}, + } + if description is not MISSING or description_localizations is not MISSING: + localizations = ( + (description_localizations or {}) + if description_localizations is not MISSING + else self.description_localizations + ) + payload['description'] = { + 'default': (description if description is not MISSING else self.description) or '', + 'localizations': {str(k): v for k, v in localizations.items()}, + } + if tagline is not MISSING or tagline_localizations is not MISSING: + localizations = ( + (tagline_localizations or {}) if tagline_localizations is not MISSING else self.tagline_localizations + ) + payload['tagline'] = { + 'default': (tagline if tagline is not MISSING else self.tagline) or '', + 'localizations': {str(k): v for k, v in localizations.items()}, + } + + if child_skus is not MISSING: + payload['child_sku_ids'] = [sku.id for sku in child_skus] if child_skus else [] + if guild is not MISSING: + payload['guild_id'] = guild.id if guild else None + if published is not MISSING: + payload['published'] = published + if carousel_items is not MISSING: + payload['carousel_items'] = ( + [ + item.to_carousel_item() if isinstance(item, StoreAsset) else {'youtube_video_id': item} + for item in carousel_items + ] + if carousel_items + else [] + ) + if preview_video is not MISSING: + payload['preview_video_asset_id'] = preview_video.id if preview_video else None + if header_background is not MISSING: + payload['header_background_asset_id'] = header_background.id if header_background else None + if hero_background is not MISSING: + payload['hero_background_asset_id'] = hero_background.id if hero_background else None + if hero_video is not MISSING: + payload['hero_video_asset_id'] = hero_video.id if hero_video else None + if box_art is not MISSING: + payload['box_art_asset_id'] = box_art.id if box_art else None + if thumbnail is not MISSING: + payload['thumbnail_asset_id'] = thumbnail.id if thumbnail else None + if header_logo_light is not MISSING: + payload['header_logo_light_theme_asset_id'] = header_logo_light.id if header_logo_light else None + if header_logo_dark is not MISSING: + payload['header_logo_dark_theme_asset_id'] = header_logo_dark.id if header_logo_dark else None + + data = await self._state.http.edit_store_listing(self.id, payload) + self._update(data, application=self.sku.application) + + @property + def url(self) -> str: + """:class:`str`: Returns the URL of the store listing. This is the URL of the primary SKU.""" + return self.sku.url + + +class SKUPrice: + """Represents a SKU's price. + + .. container:: operations + + .. describe:: bool(x) + + Checks if a SKU costs anything. + + .. describe:: int(x) + + Returns the price of the SKU. + + .. versionadded:: 2.0 + + Attributes + ----------- + currency: :class:`str` + The currency of the price. + amount: :class:`int` + The price of the SKU. + sale_amount: Optional[:class:`int`] + The price of the SKU with discounts applied, if any. + sale_percentage: :class:`int` + The percentage of the price discounted, if any. + """ + + __slots__ = ('currency', 'amount', 'sale_amount', 'sale_percentage', 'premium', 'exponent') + + def __init__(self, data: Union[SKUPricePayload, SubscriptionPricePayload]) -> None: + self.currency: str = data.get('currency', 'usd') + self.amount: int = data.get('amount', 0) + self.sale_amount: Optional[int] = data.get('sale_amount') + self.sale_percentage: int = data.get('sale_percentage', 0) + self.premium = data.get('premium') + self.exponent: Optional[int] = data.get('exponent') + + @classmethod + def from_private(cls, data: SKUPayload) -> SKUPrice: + payload: SKUPricePayload = { + 'currency': 'usd', + 'amount': data.get('price_tier') or 0, + 'sale_amount': data.get('sale_price_tier'), + } + if payload['sale_amount'] is not None: + payload['sale_percentage'] = int((1 - (payload['sale_amount'] / payload['amount'])) * 100) + return cls(payload) + + def __repr__(self) -> str: + return f'' + + def __bool__(self) -> bool: + return self.amount > 0 + + def __int__(self) -> int: + return self.amount + + def is_discounted(self) -> bool: + """:class:`bool`: Checks whether the SKU is discounted.""" + return self.sale_percentage > 0 + + def is_free(self) -> bool: + """:class:`bool`: Checks whether the SKU is free.""" + return self.amount == 0 + + @property + def discounts(self) -> int: + """:class:`int`: Returns the amount of discounts applied to the SKU price.""" + return self.amount - (self.sale_amount or self.amount) + + +class ContentRating: + """Represents a SKU's content rating. + + .. versionadded:: 2.0 + + Attributes + ----------- + agency: :class:`ContentRatingAgency` + The agency that rated the content. + rating: Union[:class:`ESRBRating`, :class:`PEGIRating`] + The rating of the content. + descriptors: Union[List[:class:`ESRBContentDescriptor`], List[:class:`PEGIContentDescriptor`] + Extra descriptors for the content rating. + """ + + _AGENCY_MAP = { + ContentRatingAgency.esrb: (ESRBRating, ESRBContentDescriptor), + ContentRatingAgency.pegi: (PEGIRating, PEGIContentDescriptor), + } + + __slots__ = ('agency', 'rating', 'descriptors') + + def __init__( + self, + *, + agency: ContentRatingAgency, + rating: Union[ESRBRating, PEGIRating], + descriptors: Union[Collection[ESRBContentDescriptor], Collection[PEGIContentDescriptor]], + ) -> None: + self.agency = agency + + ratingcls, descriptorcls = self._AGENCY_MAP[agency] + self.rating: Union[ESRBRating, PEGIRating] = try_enum(ratingcls, int(rating)) + self.descriptors: Union[List[ESRBContentDescriptor], List[PEGIContentDescriptor]] = [ + try_enum(descriptorcls, int(descriptor)) for descriptor in descriptors + ] + + @classmethod + def from_dict(cls, data: ContentRatingPayload, agency: int) -> ContentRating: + return cls( + agency=try_enum(ContentRatingAgency, agency), + rating=data.get('rating', 1), # type: ignore # Faked + descriptors=data.get('descriptors', []), # type: ignore # Faked + ) + + @classmethod + def from_dicts(cls, datas: Optional[dict]) -> List[ContentRating]: + if not datas: + return [] + return [cls.from_dict(data, int(agency)) for agency, data in datas.items()] + + def __repr__(self) -> str: + return f'' + + def to_dict(self) -> dict: + return {'rating': int(self.rating), 'descriptors': [int(descriptor) for descriptor in self.descriptors]} + + +class SKU(Hashable): + """Represents a store SKU. + + .. container:: operations + + .. describe:: x == y + + Checks if two SKUs are equal. + + .. describe:: x != y + + Checks if two SKUs are not equal. + + .. describe:: hash(x) + + Returns the SKU's hash. + + .. describe:: str(x) + + Returns the SKU's name. + + .. versionadded:: 2.0 + + Attributes + ----------- + id: :class:`int` + The SKU's ID. + name: :class:`str` + The name of the SKU. + name_localizations: Dict[:class:`Locale`, :class:`str`] + The name of the SKU localized to different languages. + summary: Optional[:class:`str`] + The SKU's summary, if any. + summary_localizations: Dict[:class:`Locale`, :class:`str`] + The summary of the SKU localized to different languages. + legal_notice: Optional[:class:`str`] + The SKU's legal notice, if any. + legal_notice_localizations: Dict[:class:`Locale`, :class:`str`] + The legal notice of the SKU localized to different languages. + type: :class:`SKUType` + The type of the SKU. + slug: :class:`str` + The URL slug of the SKU. + dependent_sku_id: Optional[:class:`int`] + The ID of the SKU that this SKU is dependent on, if any. + application_id: :class:`int` + The ID of the application that owns this SKU. + application: Optional[:class:`PartialApplication`] + The application that owns this SKU, if available. + price_tier: Optional[:class:`int`] + The price tier of the SKU. This is the base price in USD. + Not available for public SKUs. + price_overrides: Dict[:class:`str`, :class:`int`] + Price overrides for specific currencies. These override the base price tier. + Not available for public SKUs. + sale_price_tier: Optional[:class:`int`] + The sale price tier of the SKU. This is the base sale price in USD. + Not available for public SKUs. + sale_price_overrides: Dict[:class:`str`, :class:`int`] + Sale price overrides for specific currencies. These override the base sale price tier. + price: :class:`SKUPrice` + The price of the SKU. + access_level: :class:`SKUAccessLevel` + The access level of the SKU. + features: List[:class:`SKUFeature`] + A list of features that this SKU has. + locales: List[:class:`Locale`] + The locales that this SKU is available in. + genres: List[:class:`SKUGenre`] + The genres that apply to this SKU. + available_regions: Optional[List[:class:`str`]] + The regions that this SKU is available in. + If this is ``None``, then the SKU is available everywhere. + content_ratings: List[:class:`ContentRating`] + The content ratings of the SKU, if any. + For public SKUs, only the rating of your region is returned. + system_requirements: List[:class:`SystemRequirements`] + The system requirements of the SKU by operating system, if any. + release_date: Optional[:class:`datetime.date`] + The date that the SKU will released, if any. + preorder_release_date: Optional[:class:`datetime.date`] + The approximate date that the SKU will released for pre-order, if any. + preorder_released_at: Optional[:class:`datetime.datetime`] + The date that the SKU was released for pre-order, if any. + external_purchase_url: Optional[:class:`str`] + An external URL to purchase the SKU at, if applicable. + premium: :class:`bool` + Whether this SKU is provided for free to premium users. + restricted: :class:`bool` + Whether this SKU is restricted. + exclusive: :class:`bool` + Whether this SKU is exclusive to Discord. + show_age_gate: :class:`bool` + Whether the client should prompt the user to verify their age. + bundled_skus: List[:class:`SKU`] + A list of SKUs bundled with this SKU. + These are SKUs that the user will be entitled to after purchasing this parent SKU. + manifest_label_ids: List[:class:`int`] + A list of manifest label IDs that this SKU is associated with. + """ + + __slots__ = ( + 'id', + 'name', + 'name_localizations', + 'summary', + 'summary_localizations', + 'legal_notice', + 'legal_notice_localizations', + 'type', + 'slug', + 'price_tier', + 'price_overrides', + 'sale_price_tier', + 'sale_price_overrides', + 'price', + 'dependent_sku_id', + 'application_id', + 'application', + 'access_level', + 'features', + 'locales', + 'genres', + 'available_regions', + 'content_ratings', + 'system_requirements', + 'release_date', + 'preorder_release_date', + 'preorder_released_at', + 'external_purchase_url', + 'premium', + 'restricted', + 'exclusive', + 'show_age_gate', + 'bundled_skus', + 'manifests', + 'manifest_label_ids', + '_flags', + '_state', + ) + + if TYPE_CHECKING: + name: str + name_localizations: Dict[Locale, str] + summary: Optional[str] + summary_localizations: Dict[Locale, str] + legal_notice: Optional[str] + legal_notice_localizations: Dict[Locale, str] + + def __init__( + self, *, data: SKUPayload, state: ConnectionState, application: Optional[PartialApplication] = None + ) -> None: + self._state = state + self.application = application + self._update(data) + + def __str__(self) -> str: + return self.name + + def __repr__(self) -> str: + return f'' + + def _update(self, data: SKUPayload) -> None: + from .appinfo import PartialApplication + + state = self._state + + self.name, self.name_localizations = _parse_localizations(data, 'name') + self.summary, self.summary_localizations = _parse_localizations(data, 'summary') + self.legal_notice, self.legal_notice_localizations = _parse_localizations(data, 'legal_notice') + + self.id: int = int(data['id']) + self.type: SKUType = try_enum(SKUType, data['type']) + self.slug: str = data['slug'] + self.dependent_sku_id: Optional[int] = _get_as_snowflake(data, 'dependent_sku_id') + self.application_id: int = int(data['application_id']) + self.application: Optional[PartialApplication] = ( + PartialApplication(data=data['application'], state=state) + if 'application' in data + else ( + state.premium_subscriptions_application + if self.application_id == state.premium_subscriptions_application.id + else self.application + ) + ) + self._flags: int = data.get('flags', 0) + + # This hurts me, but we have two cases here: + # - The SKU is public and we get our local price/sale in the `price` field (an object in its entirety) + # - The SKU is private and we get the `price`/`sale` (overrides) and `price_tier`/`sale_price_tier` fields + # In the above case, we construct a fake price object from the fields given + # Unfortunately, in both cases, the `price` field may just be missing if there is no price set + + self.price_tier: Optional[int] = data.get('price_tier') + self.price_overrides: Dict[str, int] = data.get('price') or {} # type: ignore + self.sale_price_tier: Optional[int] = data.get('sale_price_tier') + self.sale_price_overrides: Dict[str, int] = data.get('sale_price') or {} + + if self.price_overrides and any(x in self.price_overrides for x in ('amount', 'currency')): + self.price: SKUPrice = SKUPrice(data['price']) # type: ignore + self.price_overrides = {} + else: + self.price = SKUPrice.from_private(data) + + self.access_level: SKUAccessLevel = try_enum(SKUAccessLevel, data.get('access_type', 1)) + self.features: List[SKUFeature] = [try_enum(SKUFeature, feature) for feature in data.get('features', [])] + self.locales: List[Locale] = [try_enum(Locale, locale) for locale in data.get('locales', ['en-US'])] + self.genres: List[SKUGenre] = [try_enum(SKUGenre, genre) for genre in data.get('genres', [])] + self.available_regions: Optional[List[str]] = data.get('available_regions') + self.content_ratings: List[ContentRating] = ( + [ContentRating.from_dict(data['content_rating'], data['content_rating_agency'])] + if 'content_rating' in data and 'content_rating_agency' in data + else ContentRating.from_dicts(data.get('content_ratings')) + ) + self.system_requirements: List[SystemRequirements] = [ + SystemRequirements.from_dict(try_enum(OperatingSystem, int(os)), reqs) + for os, reqs in data.get('system_requirements', {}).items() + ] + + self.release_date: Optional[date] = parse_date(data.get('release_date')) + self.preorder_release_date: Optional[date] = parse_date(data.get('preorder_approximate_release_date')) + self.preorder_released_at: Optional[datetime] = parse_time(data.get('preorder_release_at')) + self.external_purchase_url: Optional[str] = data.get('external_purchase_url') + + self.premium: bool = data.get('premium', False) + self.restricted: bool = data.get('restricted', False) + self.exclusive: bool = data.get('exclusive', False) + self.show_age_gate: bool = data.get('show_age_gate', False) + self.bundled_skus: List[SKU] = [ + SKU(data=sku, state=state, application=self.application) for sku in data.get('bundled_skus', []) + ] + + self.manifest_label_ids: List[int] = [int(label) for label in data.get('manifest_labels') or []] + + def is_free(self) -> bool: + """:class:`bool`: Checks if the SKU is free.""" + return self.price.is_free() and not self.premium + + def is_paid(self) -> bool: + """:class:`bool`: Checks if the SKU requires payment.""" + return not self.price.is_free() and not self.premium + + def is_preorder(self) -> bool: + """:class:`bool`: Checks if this SKU is a preorder.""" + return self.preorder_release_date is not None or self.preorder_released_at is not None + + def is_released(self) -> bool: + """:class:`bool`: Checks if the SKU is released.""" + return self.release_date is not None and self.release_date <= utcnow() + + def is_giftable(self) -> bool: + """:class:`bool`: Checks if this SKU is giftable.""" + return ( + self.type == SKUType.durable_primary + and self.flags.available + and not self.external_purchase_url + and self.is_paid() + ) + + def is_premium_perk(self) -> bool: + """:class:`bool`: Checks if the SKU is a perk for premium users.""" + return self.premium and (self.flags.premium_and_distribution or self.flags.premium_purchase) + + def is_premium_subscription(self) -> bool: + """:class:`bool`: Checks if the SKU is a premium subscription (e.g. Nitro or Server Boosts).""" + return self.application_id == self._state.premium_subscriptions_application.id + + def is_game_awards_winner(self) -> bool: + """:class:`bool`: Checks if the SKU is a winner of The Game Awards.""" + return self.id in THE_GAME_AWARDS_WINNERS + + @property + def url(self) -> str: + """:class:`str`: Returns the URL of the SKU.""" + return f'https://discord.com/store/skus/{self.id}/{self.slug}' + + @property + def flags(self) -> SKUFlags: + """:class:`SKUFlags`: Returns the SKU's flags.""" + return SKUFlags._from_value(self._flags) + + @property + def supported_operating_systems(self) -> List[OperatingSystem]: + """List[:class:`OperatingSystem`]: A list of supported operating systems.""" + return [reqs.os for reqs in self.system_requirements] or [OperatingSystem.windows] + + async def edit( + self, + name: str = MISSING, + name_localizations: Mapping[Locale, str] = MISSING, + legal_notice: Optional[str] = MISSING, + legal_notice_localizations: Mapping[Locale, str] = MISSING, + price_tier: Optional[int] = MISSING, + price_overrides: Mapping[str, int] = MISSING, + sale_price_tier: Optional[int] = MISSING, + sale_price_overrides: Mapping[str, int] = MISSING, + dependent_sku: Optional[Snowflake] = MISSING, + flags: SKUFlags = MISSING, + access_level: SKUAccessLevel = MISSING, + features: Collection[SKUFeature] = MISSING, + locales: Collection[Locale] = MISSING, + genres: Collection[SKUGenre] = MISSING, + content_ratings: Collection[ContentRating] = MISSING, + system_requirements: Collection[SystemRequirements] = MISSING, + release_date: Optional[date] = MISSING, + bundled_skus: Sequence[Snowflake] = MISSING, + manifest_labels: Sequence[Snowflake] = MISSING, + ) -> None: + """|coro| + + Edits the SKU. + + All parameters are optional. + + Parameters + ----------- + name: :class:`str` + The SKU's name. + name_localizations: Dict[:class:`Locale`, :class:`str`] + The SKU's name localized to other languages. + legal_notice: Optional[:class:`str`] + The SKU's legal notice. + legal_notice_localizations: Dict[:class:`Locale`, :class:`str`] + The SKU's legal notice localized to other languages. + price_tier: Optional[:class:`int`] + The price tier of the SKU. + This is the base price in USD that other currencies will be calculated from. + price_overrides: Dict[:class:`str`, :class:`int`] + A mapping of currency to price. These prices override the base price tier. + sale_price_tier: Optional[:class:`int`] + The sale price tier of the SKU. + This is the base sale price in USD that other currencies will be calculated from. + sale_price_overrides: Dict[:class:`str`, :class:`int`] + A mapping of currency to sale price. These prices override the base sale price tier. + dependent_sku: Optional[:class:`SKU`] + The ID of the SKU that this SKU is dependent on. + flags: :class:`SKUFlags` + The SKU's flags. + access_level: :class:`SKUAccessLevel` + The access level of the SKU. + features: List[:class:`SKUFeature`] + A list of features of the SKU. + locales: List[:class:`Locale`] + A list of locales supported by the SKU. + genres: List[:class:`SKUGenre`] + A list of genres of the SKU. + content_ratings: List[:class:`ContentRating`] + A list of content ratings of the SKU. + system_requirements: List[:class:`SystemRequirements`] + A list of system requirements of the SKU. + release_date: Optional[:class:`datetime.date`] + The release date of the SKU. + bundled_skus: List[:class:`SKU`] + A list SKUs that are bundled with this SKU. + manifest_labels: List[:class:`ManifestLabel`] + A list of manifest labels for the SKU. + + Raises + ------ + Forbidden + You do not have access to edit the SKU. + HTTPException + Editing the SKU failed. + """ + payload = {} + if name is not MISSING or name_localizations is not MISSING: + payload['name'] = { + 'default': name or self.name, + 'localizations': { + str(k): v + for k, v in ( + (name_localizations or {}) if name_localizations is not MISSING else self.name_localizations + ).items() + }, + } + if legal_notice or legal_notice_localizations: + payload['legal_notice'] = { + 'default': legal_notice, + 'localizations': { + str(k): v + for k, v in ( + (legal_notice_localizations or {}) + if legal_notice_localizations is not MISSING + else self.legal_notice_localizations + ).items() + }, + } + if price_tier is not MISSING: + payload['price_tier'] = price_tier + if price_overrides is not MISSING: + payload['price'] = {str(k): v for k, v in price_overrides.items()} + if sale_price_tier is not MISSING: + payload['sale_price_tier'] = sale_price_tier + if sale_price_overrides is not MISSING: + payload['sale_price'] = {str(k): v for k, v in (sale_price_overrides or {}).items()} + if dependent_sku is not MISSING: + payload['dependent_sku_id'] = dependent_sku.id if dependent_sku else None + if flags is not MISSING: + payload['flags'] = flags.value if flags else 0 + if access_level is not MISSING: + payload['access_level'] = int(access_level) + if locales is not MISSING: + payload['locales'] = [str(l) for l in locales] if locales else [] + if features is not MISSING: + payload['features'] = [int(f) for f in features] if features else [] + if genres is not MISSING: + payload['genres'] = [int(g) for g in genres] if genres else [] + if content_ratings is not MISSING: + payload['content_ratings'] = ( + {content_rating.agency: content_rating.to_dict() for content_rating in content_ratings} + if content_ratings + else {} + ) + if system_requirements is not MISSING: + payload['system_requirements'] = ( + {system_requirement.os: system_requirement.to_dict() for system_requirement in system_requirements} + if system_requirements + else {} + ) + if release_date is not MISSING: + payload['release_date'] = release_date.isoformat() if release_date else None + if bundled_skus is not MISSING: + payload['bundled_skus'] = [s.id for s in bundled_skus] if bundled_skus else [] + if manifest_labels is not MISSING: + payload['manifest_labels'] = [m.id for m in manifest_labels] if manifest_labels else [] + + data = await self._state.http.edit_sku(self.id, **payload) + self._update(data) + + async def subscription_plans( + self, + *, + country_code: str = MISSING, + payment_source: Snowflake = MISSING, + with_unpublished: bool = False, + ) -> List[SubscriptionPlan]: + r"""|coro| + + Returns a list of :class:`SubscriptionPlan`\s for this SKU. + + .. versionadded:: 2.0 + + Parameters + ---------- + country_code: :class:`str` + The country code to retrieve the subscription plan prices for. + Defaults to the country code of the current user. + payment_source: :class:`PaymentSource` + The specific payment source to retrieve the subscription plan prices for. + Defaults to all payment sources of the current user. + with_unpublished: :class:`bool` + Whether to include unpublished subscription plans. + + If ``True``, then you require access to the application. + + Raises + ------ + HTTPException + Retrieving the subscription plans failed. + + Returns + ------- + List[:class:`.SubscriptionPlan`] + The subscription plans for this SKU. + """ + state = self._state + data = await state.http.get_store_listing_subscription_plans( + self.id, + country_code=country_code if country_code is not MISSING else None, + payment_source_id=payment_source.id if payment_source is not MISSING else None, + include_unpublished=with_unpublished, + ) + return [SubscriptionPlan(state=state, data=d) for d in data] + + async def store_listings(self, localize: bool = True) -> List[StoreListing]: + r"""|coro| + + Returns a list of :class:`StoreListing`\s for this SKU. + + Parameters + ----------- + localize: :class:`bool` + Whether to localize the store listings to the current user's locale. + If ``False`` then all localizations are returned. + + Raises + ------ + Forbidden + You do not have access to fetch store listings. + HTTPException + Retrieving the store listings failed. + + Returns + ------- + List[:class:`StoreListing`] + The store listings for this SKU. + """ + data = await self._state.http.get_sku_store_listings(self.id, localize=localize) + return [StoreListing(data=listing, state=self._state, application=self.application) for listing in data] + + async def create_store_listing( + self, + *, + summary: str, + summary_localizations: Optional[Mapping[Locale, str]] = None, + description: str, + description_localizations: Optional[Mapping[Locale, str]] = None, + tagline: Optional[str] = None, + tagline_localizations: Optional[Mapping[Locale, str]] = None, + child_skus: Optional[Collection[Snowflake]] = None, + guild: Optional[Snowflake] = None, + published: bool = False, + carousel_items: Optional[Collection[Union[StoreAsset, str]]] = None, + preview_video: Optional[Snowflake] = None, + header_background: Optional[Snowflake] = None, + hero_background: Optional[Snowflake] = None, + hero_video: Optional[Snowflake] = None, + box_art: Optional[Snowflake] = None, + thumbnail: Optional[Snowflake] = None, + header_logo_light: Optional[Snowflake] = None, + header_logo_dark: Optional[Snowflake] = None, + ) -> StoreListing: + """|coro| + + Creates a a store listing for this SKU. + + Parameters + ---------- + summary: :class:`str` + The summary of the store listing. + summary_localizations: Optional[Dict[:class:`Locale`, :class:`str`]] + The summary of the store listing localized to different languages. + description: :class:`str` + The description of the store listing. + description_localizations: Optional[Dict[:class:`Locale`, :class:`str`]] + The description of the store listing localized to different languages. + tagline: Optional[:class:`str`] + The tagline of the store listing. + tagline_localizations: Optional[Dict[:class:`Locale`, :class:`str`]] + The tagline of the store listing localized to different languages. + child_skus: Optional[List[:class:`SKU`]] + The child SKUs of the store listing. + guild: Optional[:class:`Guild`] + The guild that the store listing is for. + published: :class:`bool` + Whether the store listing is published. + carousel_items: Optional[List[Union[:class:`StoreAsset`, :class:`str`]]] + A list of carousel items to add to the store listing. These can be store assets or YouTube video IDs. + preview_video: Optional[:class:`StoreAsset`] + The preview video of the store listing. + header_background: Optional[:class:`StoreAsset`] + The header background of the store listing. + hero_background: Optional[:class:`StoreAsset`] + The hero background of the store listing. + hero_video: Optional[:class:`StoreAsset`] + The hero video of the store listing. + box_art: Optional[:class:`StoreAsset`] + The box art of the store listing. + thumbnail: Optional[:class:`StoreAsset`] + The thumbnail of the store listing. + header_logo_light: Optional[:class:`StoreAsset`] + The header logo image for light backgrounds. + header_logo_dark: Optional[:class:`StoreAsset`] + The header logo image for dark backgrounds. + + Raises + ------ + Forbidden + You do not have permissions to edit the store listing. + HTTPException + Editing the store listing failed. + """ + payload: Dict[str, Any] = { + 'summary': { + 'default': summary or '', + 'localizations': {str(k): v for k, v in (summary_localizations or {}).items()}, + }, + 'description': { + 'default': description or '', + 'localizations': {str(k): v for k, v in (description_localizations or {}).items()}, + }, + } + + if tagline or tagline_localizations: + payload['tagline'] = { + 'default': tagline or '', + 'localizations': {str(k): v for k, v in (tagline_localizations or {}).items()}, + } + if child_skus: + payload['child_sku_ids'] = [sku.id for sku in child_skus] + if guild: + payload['guild_id'] = guild.id + if published: + payload['published'] = True + if carousel_items: + payload['carousel_items'] = [ + item.to_carousel_item() if isinstance(item, StoreAsset) else {'youtube_video_id': item} + for item in carousel_items + ] + if preview_video: + payload['preview_video_asset_id'] = preview_video.id + if header_background: + payload['header_background_asset_id'] = header_background.id + if hero_background: + payload['hero_background_asset_id'] = hero_background.id + if hero_video: + payload['hero_video_asset_id'] = hero_video.id + if box_art: + payload['box_art_asset_id'] = box_art.id + if thumbnail: + payload['thumbnail_asset_id'] = thumbnail.id + if header_logo_light: + payload['header_logo_light_theme_asset_id'] = header_logo_light.id + if header_logo_dark: + payload['header_logo_dark_theme_asset_id'] = header_logo_dark.id + + data = await self._state.http.create_store_listing(self.application_id, self.id, payload) + return StoreListing(data=data, state=self._state, application=self.application) + + async def create_discount(self, user: Snowflake, percent_off: int, *, ttl: int = 3600) -> None: + """|coro| + + Creates a discount for this SKU for a user. + + This discount will be applied to the user's next purchase of this SKU. + + Parameters + ---------- + user: :class:`User` + The user to create the discount for. + percent_off: :class:`int` + The discount in the form of a percentage off the price to give the user. + ttl: :class:`int` + How long the discount should last for in seconds. + Minimum 60 seconds, maximum 3600 seconds. + + Raises + ------ + Forbidden + You do not have permissions to create the discount. + HTTPException + Creating the discount failed. + """ + await self._state.http.create_sku_discount(self.id, user.id, percent_off, ttl) + + async def delete_discount(self, user: Snowflake) -> None: + """|coro| + + Deletes a discount for this SKU for a user. + + You do not need to call this after a discounted purchase has been made, + as the discount will be automatically consumed and deleted. + + Parameters + ---------- + user: :class:`User` + The user to delete the discount for. + + Raises + ------ + Forbidden + You do not have permissions to delete the discount. + HTTPException + Deleting the discount failed. + """ + await self._state.http.delete_sku_discount(self.id, user.id) + + async def create_gift_batch( + self, + *, + amount: int, + description: str, + entitlement_branches: Optional[List[Snowflake]] = None, + entitlement_starts_at: Optional[date] = None, + entitlement_ends_at: Optional[date] = None, + ) -> GiftBatch: + """|coro| + + Creates a gift batch for this SKU. + + Parameters + ----------- + amount: :class:`int` + The amount of gifts to create in the batch. + description: :class:`str` + The description of the gift batch. + entitlement_branches: List[:class:`ApplicationBranch`] + The branches to grant in the gifts. + entitlement_starts_at: :class:`datetime.date` + When the entitlement is valid from. + entitlement_ends_at: :class:`datetime.date` + When the entitlement is valid until. + + Raises + ------ + Forbidden + You do not have permissions to create a gift batch. + HTTPException + Creating the gift batch failed. + + Returns + ------- + :class:`GiftBatch` + The gift batch created. + """ + from .entitlements import GiftBatch + + state = self._state + app_id = self.application_id + data = await state.http.create_gift_batch( + app_id, + self.id, + amount, + description, + entitlement_branches=[branch.id for branch in entitlement_branches] if entitlement_branches else None, + entitlement_starts_at=entitlement_starts_at.isoformat() if entitlement_starts_at else None, + entitlement_ends_at=entitlement_ends_at.isoformat() if entitlement_ends_at else None, + ) + return GiftBatch(data=data, state=state, application_id=app_id) + + async def gifts(self, subscription_plan: Optional[Snowflake] = None) -> List[Gift]: + """|coro| + + Retrieves the gifts purchased for this SKU. + + Parameters + ---------- + subscription_plan: Optional[:class:`SubscriptionPlan`] + The subscription plan to retrieve the gifts for. + + Raises + ------ + HTTPException + Retrieving the gifts failed. + + Returns + ------- + List[:class:`Gift`] + The gifts that have been purchased for this SKU. + """ + from .entitlements import Gift + + data = await self._state.http.get_sku_gifts(self.id, subscription_plan.id if subscription_plan else None) + return [Gift(data=gift, state=self._state) for gift in data] + + async def create_gift( + self, *, subscription_plan: Optional[Snowflake] = None, gift_style: Optional[GiftStyle] = None + ) -> Gift: + """|coro| + + Creates a gift for this SKU. + + You must have a giftable entitlement for this SKU to create a gift. + + Parameters + ----------- + subscription_plan: Optional[:class:`SubscriptionPlan`] + The subscription plan to gift. + gift_style: Optional[:class:`GiftStyle`] + The style of the gift. + + Raises + ------ + Forbidden + You do not have permissions to create a gift. + HTTPException + Creating the gift failed. + + Returns + ------- + :class:`Gift` + The gift created. + """ + from .entitlements import Gift + + state = self._state + data = await state.http.create_gift( + self.id, + subscription_plan_id=subscription_plan.id if subscription_plan else None, + gift_style=int(gift_style) if gift_style else None, + ) + return Gift(data=data, state=state) + + async def preview_purchase( + self, payment_source: Snowflake, *, subscription_plan: Optional[Snowflake] = None, test_mode: bool = False + ) -> SKUPrice: + """|coro| + + Previews a purchase of this SKU. + + Parameters + ---------- + payment_source: :class:`PaymentSource` + The payment source to use for the purchase. + subscription_plan: Optional[:class:`SubscriptionPlan`] + The subscription plan being purchased. + test_mode: :class:`bool` + Whether to preview the purchase in test mode. + + Raises + ------ + HTTPException + Previewing the purchase failed. + + Returns + ------- + :class:`SKUPrice` + The previewed purchase price. + """ + data = await self._state.http.preview_sku_purchase( + self.id, payment_source.id, subscription_plan.id if subscription_plan else None, test_mode=test_mode + ) + return SKUPrice(data=data) + + async def purchase( + self, + payment_source: Optional[Snowflake] = None, + *, + subscription_plan: Optional[Snowflake] = None, + expected_amount: Optional[int] = None, + expected_currency: Optional[str] = None, + gift: bool = False, + gift_style: Optional[GiftStyle] = None, + test_mode: bool = False, + payment_source_token: Optional[str] = None, + purchase_token: Optional[str] = None, + return_url: Optional[str] = None, + gateway_checkout_context: Optional[str] = None, + ) -> Tuple[List[Entitlement], List[LibraryApplication], Optional[Gift]]: + """|coro| + + Purchases this SKU. + + Parameters + ---------- + payment_source: Optional[:class:`PaymentSource`] + The payment source to use for the purchase. + Not required for free SKUs. + subscription_plan: Optional[:class:`SubscriptionPlan`] + The subscription plan to purchase. + Can only be used for premium subscription SKUs. + expected_amount: Optional[:class:`int`] + The expected amount of the purchase. + This can be gotten from :attr:`price` or :meth:`preview_purchase`. + + If the value passed here does not match the actual purchase amount, + the purchase will error. + expected_currency: Optional[:class:`str`] + The expected currency of the purchase. + This can be gotten from :attr:`price` or :meth:`preview_purchase`. + + If the value passed here does not match the actual purchase currency, + the purchase will error. + gift: :class:`bool` + Whether to purchase the SKU as a gift. + Certain requirements must be met for this to be possible. + gift_style: Optional[:class:`GiftStyle`] + The style of the gift. Only applicable if ``gift`` is ``True``. + test_mode: :class:`bool` + Whether to purchase the SKU in test mode. + payment_source_token: Optional[:class:`str`] + The token used to authorize with the payment source. + purchase_token: Optional[:class:`str`] + The purchase token to use. + return_url: Optional[:class:`str`] + The URL to return to after the payment is complete. + gateway_checkout_context: Optional[:class:`str`] + The current checkout context. + + Raises + ------ + TypeError + ``gift_style`` was passed but ``gift`` was not ``True``. + HTTPException + Purchasing the SKU failed. + + Returns + ------- + Tuple[List[:class:`Entitlement`], List[:class:`LibraryApplication`], Optional[:class:`Gift`]] + The purchased entitlements, the library entries created, and the gift created (if any). + """ + if not gift and gift_style: + raise TypeError('gift_style can only be used with gifts') + + state = self._state + data = await state.http.purchase_sku( + self.id, + payment_source.id if payment_source else None, + subscription_plan_id=subscription_plan.id if subscription_plan else None, + expected_amount=expected_amount, + expected_currency=expected_currency, + gift=gift, + gift_style=int(gift_style) if gift_style else None, + test_mode=test_mode, + payment_source_token=payment_source_token, + purchase_token=purchase_token, + return_url=return_url, + gateway_checkout_context=gateway_checkout_context, + ) + + from .entitlements import Entitlement, Gift + from .library import LibraryApplication + + entitlements = [Entitlement(state=state, data=entitlement) for entitlement in data.get('entitlements', [])] + library_applications = [ + LibraryApplication(state=state, data=application) for application in data.get('library_applications', []) + ] + gift_code = data.get('gift_code') + gift_ = None + if gift_code: + # We create fake gift data + gift_data: GiftPayload = { + 'code': gift_code, + 'application_id': self.application_id, + 'subscription_plan_id': subscription_plan.id if subscription_plan else None, + 'sku_id': self.id, + 'gift_style': int(gift_style) if gift_style else None, # type: ignore # Enum is identical + 'max_uses': 1, + 'uses': 0, + 'user': state.user._to_minimal_user_json(), # type: ignore + } + gift_ = Gift(state=state, data=gift_data) + if subscription_plan and isinstance(subscription_plan, SubscriptionPlan): + gift_.subscription_plan = subscription_plan + + return entitlements, library_applications, gift_ + + +class SubscriptionPlanPrices: + """Represents the different prices for a :class:`SubscriptionPlan`. + + .. versionadded:: 2.0 + + Attributes + ---------- + country_code: :class:`str` + The country code the country prices are for. + country_prices: List[:class:`SKUPrice`] + The prices for the country the plan is being purchased in. + payment_source_prices: Dict[:class:`int`, List[:class:`SKUPrice`]] + A mapping of payment source IDs to the prices for that payment source. + """ + + def __init__(self, data: SubscriptionPricesPayload): + country_prices = data.get('country_prices') or {} + payment_source_prices = data.get('payment_source_prices') or {} + + self.country_code: str = country_prices.get('country_code', 'US') + self.country_prices: List[SKUPrice] = [SKUPrice(data=price) for price in country_prices.get('prices', [])] + self.payment_source_prices: Dict[int, List[SKUPrice]] = { + int(payment_source_id): [SKUPrice(data=price) for price in prices] + for payment_source_id, prices in payment_source_prices.items() + } + + def __repr__(self) -> str: + return f'' + + +class SubscriptionPlan(Hashable): + """Represents a subscription plan for a :class:`SKU`. + + .. container:: operations + + .. describe:: x == y + + Checks if two subscription plans are equal. + + .. describe:: x != y + + Checks if two subscription plans are not equal. + + .. describe:: hash(x) + + Returns the subscription plan's hash. + + .. describe:: str(x) + + Returns the subscription plan's name. + + .. versionadded:: 2.0 + + Attributes + ---------- + id: :class:`int` + The ID of the subscription plan. + name: :class:`str` + The name of the subscription plan. + sku_id: :class:`int` + The ID of the SKU that this subscription plan is for. + interval: :class:`SubscriptionInterval` + The interval of the subscription plan. + interval_count: :class:`int` + The number of intervals that make up a subscription period. + tax_inclusive: :class:`bool` + Whether the subscription plan price is tax inclusive. + prices: Dict[:class:`SubscriptionPlanPurchaseType`, :class:`SubscriptionPlanPrices`] + The different prices of the subscription plan. + Not available in some contexts. + currency: Optional[:class:`str`] + The currency of the subscription plan's price. + Not available in some contexts. + price: Optional[:class:`int`] + The price of the subscription plan. + Not available in some contexts. + discount_price: Optional[:class:`int`] + The discounted price of the subscription plan. + This price is the one premium subscribers will pay, and is only available for premium subscribers. + fallback_currency: Optional[:class:`str`] + The fallback currency of the subscription plan's price. + This is the currency that will be used for gifting if the user's currency is not giftable. + fallback_price: Optional[:class:`int`] + The fallback price of the subscription plan. + This is the price that will be used for gifting if the user's currency is not giftable. + fallback_discount_price: Optional[:class:`int`] + The fallback discounted price of the subscription plan. + This is the discounted price that will be used for gifting if the user's currency is not giftable. + """ + + _INTERVAL_TABLE = { + SubscriptionInterval.day: 1, + SubscriptionInterval.month: 30, + SubscriptionInterval.year: 365, + } + + __slots__ = ( + 'id', + 'name', + 'sku_id', + 'interval', + 'interval_count', + 'tax_inclusive', + 'prices', + 'currency', + 'price_tier', + 'price', + 'discount_price', + 'fallback_currency', + 'fallback_price', + 'fallback_discount_price', + '_state', + ) + + def __init__( + self, *, data: Union[PartialSubscriptionPlanPayload, SubscriptionPlanPayload], state: ConnectionState + ) -> None: + self._state = state + self._update(data) + + def _update(self, data: Union[PartialSubscriptionPlanPayload, SubscriptionPlanPayload]) -> None: + self.id: int = int(data['id']) + self.name: str = data['name'] + self.sku_id: int = int(data['sku_id']) + self.interval: SubscriptionInterval = try_enum(SubscriptionInterval, data['interval']) + self.interval_count: int = data['interval_count'] + self.tax_inclusive: bool = data['tax_inclusive'] + + self.prices: Dict[SubscriptionPlanPurchaseType, SubscriptionPlanPrices] = { + try_enum(SubscriptionPlanPurchaseType, int(purchase_type)): SubscriptionPlanPrices(data=price_data) + for purchase_type, price_data in (data.get('prices') or {}).items() + } + self.currency: Optional[str] = data.get('currency') + self.price_tier: Optional[int] = data.get('price_tier') + self.price: Optional[int] = data.get('price') + self.discount_price: Optional[int] = data.get('discount_price') + self.fallback_currency: Optional[str] = data.get('fallback_currency') + self.fallback_price: Optional[int] = data.get('fallback_price') + self.fallback_discount_price: Optional[int] = data.get('fallback_discount_price') + + def __repr__(self) -> str: + return f'' + + def __str__(self) -> str: + return self.name + + @property + def duration(self) -> timedelta: + """:class:`datetime.timedelta`: How long the subscription plan lasts.""" + return timedelta(days=self.interval_count * self._INTERVAL_TABLE[self.interval]) + + @property + def premium_type(self) -> Optional[PremiumType]: + """Optional[:class:`PremiumType`]: The premium type of the subscription plan, if it is a premium subscription.""" + return PremiumType.from_sku_id(self.sku_id) + + async def gifts(self) -> List[Gift]: + """|coro| + + Retrieves the gifts purchased for this subscription plan. + + Raises + ------ + HTTPException + Retrieving the gifts failed. + + Returns + ------- + List[:class:`Gift`] + The gifts that have been purchased for this SKU. + """ + from .entitlements import Gift + + data = await self._state.http.get_sku_gifts(self.sku_id, self.id) + return [Gift(data=gift, state=self._state) for gift in data] + + async def create_gift(self, *, gift_style: Optional[GiftStyle] = None) -> Gift: + """|coro| + + Creates a gift for this subscription plan. + + You must have a giftable entitlement for this subscription plan to create a gift. + + Parameters + ----------- + gift_style: Optional[:class:`GiftStyle`] + The style of the gift. + + Raises + ------ + Forbidden + You do not have permissions to create a gift. + HTTPException + Creating the gift failed. + + Returns + ------- + :class:`Gift` + The gift created. + """ + from .entitlements import Gift + + state = self._state + data = await state.http.create_gift( + self.sku_id, + subscription_plan_id=self.id, + gift_style=int(gift_style) if gift_style else None, + ) + return Gift(data=data, state=state) + + async def preview_purchase(self, payment_source: Snowflake, *, test_mode: bool = False) -> SKUPrice: + """|coro| + + Previews a purchase of this subscription plan. + + Parameters + ---------- + payment_source: :class:`PaymentSource` + The payment source to use for the purchase. + test_mode: :class:`bool` + Whether to preview the purchase in test mode. + + Raises + ------ + HTTPException + Previewing the purchase failed. + + Returns + ------- + :class:`SKUPrice` + The previewed purchase price. + """ + data = await self._state.http.preview_sku_purchase(self.id, payment_source.id, self.id, test_mode=test_mode) + return SKUPrice(data=data) + + async def purchase( + self, + payment_source: Optional[Snowflake] = None, + *, + expected_amount: Optional[int] = None, + expected_currency: Optional[str] = None, + gift: bool = False, + gift_style: Optional[GiftStyle] = None, + test_mode: bool = False, + payment_source_token: Optional[str] = None, + purchase_token: Optional[str] = None, + return_url: Optional[str] = None, + gateway_checkout_context: Optional[str] = None, + ) -> Tuple[List[Entitlement], List[LibraryApplication], Optional[Gift]]: + """|coro| + + Purchases this subscription plan. + + This can only be used on premium subscription plans. + + Parameters + ---------- + payment_source: Optional[:class:`PaymentSource`] + The payment source to use for the purchase. + Not required for free subscription plans. + expected_amount: Optional[:class:`int`] + The expected amount of the purchase. + This can be gotten from :attr:`price` or :meth:`preview_purchase`. + + If the value passed here does not match the actual purchase amount, + the purchase will error. + expected_currency: Optional[:class:`str`] + The expected currency of the purchase. + This can be gotten from :attr:`price` or :meth:`preview_purchase`. + + If the value passed here does not match the actual purchase currency, + the purchase will error. + gift: :class:`bool` + Whether to purchase the subscription plan as a gift. + Certain requirements must be met for this to be possible. + gift_style: Optional[:class:`GiftStyle`] + The style of the gift. Only applicable if ``gift`` is ``True``. + test_mode: :class:`bool` + Whether to purchase the subscription plan in test mode. + payment_source_token: Optional[:class:`str`] + The token used to authorize with the payment source. + purchase_token: Optional[:class:`str`] + The purchase token to use. + return_url: Optional[:class:`str`] + The URL to return to after the payment is complete. + gateway_checkout_context: Optional[:class:`str`] + The current checkout context. + + Raises + ------ + TypeError + ``gift_style`` was passed but ``gift`` was not ``True``. + HTTPException + Purchasing the subscription plan failed. + + Returns + ------- + Tuple[List[:class:`Entitlement`], List[:class:`LibraryApplication`], Optional[:class:`Gift`]] + The purchased entitlements, the library entries created, and the gift created (if any). + """ + if not gift and gift_style: + raise TypeError('gift_style can only be used with gifts') + + state = self._state + data = await self._state.http.purchase_sku( + self.sku_id, + payment_source.id if payment_source else None, + subscription_plan_id=self.id, + expected_amount=expected_amount, + expected_currency=expected_currency, + gift=gift, + gift_style=int(gift_style) if gift_style else None, + test_mode=test_mode, + payment_source_token=payment_source_token, + purchase_token=purchase_token, + return_url=return_url, + gateway_checkout_context=gateway_checkout_context, + ) + + from .entitlements import Entitlement, Gift + from .library import LibraryApplication + + entitlements = [Entitlement(state=state, data=entitlement) for entitlement in data.get('entitlements', [])] + library_applications = [ + LibraryApplication(state=state, data=application) for application in data.get('library_applications', []) + ] + gift_code = data.get('gift_code') + gift_ = None + if gift_code: + # We create fake gift data + gift_data: GiftPayload = { + 'code': gift_code, + 'subscription_plan_id': self.id, + 'sku_id': self.sku_id, + 'gift_style': int(gift_style) if gift_style else None, # type: ignore # Enum is identical + 'max_uses': 1, + 'uses': 0, + 'user': state.user._to_minimal_user_json(), # type: ignore + } + gift_ = Gift(state=state, data=gift_data) + gift_.subscription_plan = self + + return entitlements, library_applications, gift_ diff --git a/discord/subscriptions.py b/discord/subscriptions.py new file mode 100644 index 000000000..c4dc22bd9 --- /dev/null +++ b/discord/subscriptions.py @@ -0,0 +1,859 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Dolfies + +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 datetime import datetime, timedelta +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union + +from .billing import PaymentSource +from .enums import ( + PaymentGateway, + SubscriptionDiscountType, + SubscriptionInterval, + SubscriptionInvoiceStatus, + SubscriptionStatus, + SubscriptionType, + try_enum, +) +from .metadata import Metadata +from .mixins import Hashable +from .utils import MISSING, _get_as_snowflake, parse_time, snowflake_time, utcnow + +if TYPE_CHECKING: + from typing_extensions import Self + + from .abc import Snowflake + from .guild import Guild + from .state import ConnectionState + from .types.subscriptions import ( + SubscriptionDiscount as SubscriptionDiscountPayload, + SubscriptionInvoice as SubscriptionInvoicePayload, + SubscriptionInvoiceItem as SubscriptionInvoiceItemPayload, + SubscriptionItem as SubscriptionItemPayload, + SubscriptionRenewalMutations as SubscriptionRenewalMutationsPayload, + PartialSubscription as PartialSubscriptionPayload, + Subscription as SubscriptionPayload, + SubscriptionTrial as SubscriptionTrialPayload, + ) + +__all__ = ( + 'SubscriptionItem', + 'SubscriptionDiscount', + 'SubscriptionInvoiceItem', + 'SubscriptionInvoice', + 'SubscriptionRenewalMutations', + 'Subscription', + 'SubscriptionTrial', +) + + +class SubscriptionItem(Hashable): + """Represents a Discord subscription item. + + .. container:: operations + + .. describe:: x == y + + Checks if two subscription items are equal. + + .. describe:: x != y + + Checks if two subscription items are not equal. + + .. describe:: hash(x) + + Returns the item's hash. + + .. describe:: len(x) + + Returns the quantity of the subscription item. + + .. versionadded:: 2.0 + + Attributes + ---------- + id: Optional[:class:`int`] + The ID of the subscription item. Always available when received from the API. + quantity: :class:`int` + How many of the item have been/are being purchased. + plan_id: :class:`int` + The ID of the plan the item is for. + """ + + __slots__ = ('id', 'quantity', 'plan_id') + + def __init__(self, *, id: Optional[int] = None, plan_id: int, quantity: int = 1) -> None: + self.id: Optional[int] = id + self.quantity: int = quantity + self.plan_id: int = plan_id + + def __repr__(self) -> str: + return f'' + + def __len__(self) -> int: + return self.quantity + + @classmethod + def from_dict(cls, data: SubscriptionItemPayload) -> Self: + return cls(id=int(data['id']), plan_id=int(data['plan_id']), quantity=int(data.get('quantity', 1))) + + def to_dict(self, with_id: bool = True) -> dict: + data = { + 'quantity': self.quantity, + 'plan_id': self.plan_id, + } + if self.id and with_id: + data['id'] = self.id + + return data + + +class SubscriptionDiscount: + """Represents a discount on a Discord subscription item. + + .. container:: operations + + .. describe:: int(x) + + Returns the discount's amount. + + .. versionadded:: 2.0 + + Attributes + ---------- + type: :class:`SubscriptionDiscountType` + The type of the discount. + amount: :class:`int` + How much the discount is. + """ + + __slots__ = ('type', 'amount') + + def __init__(self, data: SubscriptionDiscountPayload) -> None: + self.type: SubscriptionDiscountType = try_enum(SubscriptionDiscountType, data['type']) + self.amount: int = data['amount'] + + def __repr__(self) -> str: + return f'' + + def __int__(self) -> int: + return self.amount + + +class SubscriptionInvoiceItem(Hashable): + """Represents an invoice item. + + .. container:: operations + + .. describe:: x == y + + Checks if two invoice items are equal. + + .. describe:: x != y + + Checks if two invoice items are not equal. + + .. describe:: hash(x) + + Returns the invoice's hash. + + .. describe:: len(x) + + Returns the quantity of the invoice item. + + .. versionadded:: 2.0 + + Attributes + ---------- + id: :class:`int` + The ID of the invoice item. + quantity: :class:`int` + How many of the item have been/are being purchased. + amount: :class:`int` + The price of the item. This includes discounts. + proration: :class:`bool` + Whether the item is prorated. + plan_id: :class:`int` + The ID of the subscription plan the item represents. + plan_price: :class:`int` + The price of the subscription plan the item represents. This does not include discounts. + discounts: List[:class:`SubscriptionDiscount`] + A list of discounts applied to the item. + """ + + __slots__ = ('id', 'quantity', 'amount', 'proration', 'plan_id', 'plan_price', 'discounts') + + def __init__(self, data: SubscriptionInvoiceItemPayload) -> None: + self.id: int = int(data['id']) + self.quantity: int = data['quantity'] + self.amount: int = data['amount'] + self.proration: bool = data.get('proration', False) + self.plan_id: int = int(data['subscription_plan_id']) + self.plan_price: int = data['subscription_plan_price'] + self.discounts: List[SubscriptionDiscount] = [SubscriptionDiscount(d) for d in data['discounts']] + + def __repr__(self) -> str: + return f'' + + def __len__(self) -> int: + return self.quantity + + @property + def savings(self) -> int: + """:class:`int`: The total amount of discounts on the invoice item.""" + return self.plan_price - self.amount + + def is_discounted(self) -> bool: + """:class:`bool`: Indicates if the invoice item has a discount.""" + return bool(self.discounts) + + def is_trial(self) -> bool: + """:class:`bool`: Indicates if the invoice item is a trial.""" + return not self.amount or any(discount.type is SubscriptionDiscountType.premium_trial for discount in self.discounts) + + +class SubscriptionInvoice(Hashable): + """Represents an invoice for a Discord subscription. + + .. container:: operations + + .. describe:: x == y + + Checks if two invoices are equal. + + .. describe:: x != y + + Checks if two invoices are not equal. + + .. describe:: hash(x) + + Returns the invoice's hash. + + .. versionadded:: 2.0 + + Attributes + ---------- + subscription: Optional[:class:`Subscription`] + The subscription the invoice is for. Not available for new subscription previews. + id: :class:`int` + The ID of the invoice. + status: Optional[:class:`SubscriptionInvoiceStatus`] + The status of the invoice. Not available for subscription previews. + currency: :class:`str` + The currency the invoice is in. + subtotal: :class:`int` + The subtotal of the invoice. + tax: :class:`int` + The tax applied to the invoice. + total: :class:`int` + The total of the invoice. + tax_inclusive: :class:`bool` + Whether the subtotal is inclusive of all taxes. + items: List[:class:`SubscriptionInvoiceItem`] + The items in the invoice. + current_period_start: :class:`datetime.datetime` + When the current billing period started. + current_period_end: :class:`datetime.datetime` + When the current billing period ends. + """ + + __slots__ = ( + '_state', + 'subscription', + 'id', + 'status', + 'currency', + 'subtotal', + 'tax', + 'total', + 'tax_inclusive', + 'items', + 'current_period_start', + 'current_period_end', + ) + + def __init__( + self, subscription: Optional[Subscription], *, data: SubscriptionInvoicePayload, state: ConnectionState + ) -> None: + self._state = state + self.subscription = subscription + self._update(data) + + def _update(self, data: SubscriptionInvoicePayload) -> None: + self.id: int = int(data['id']) + self.status: Optional[SubscriptionInvoiceStatus] = ( + try_enum(SubscriptionInvoiceStatus, data['status']) if 'status' in data else None + ) + self.currency: str = data['currency'] + self.subtotal: int = data['subtotal'] + self.tax: int = data.get('tax', 0) + self.total: int = data['total'] + self.tax_inclusive: bool = data['tax_inclusive'] + self.items: List[SubscriptionInvoiceItem] = [SubscriptionInvoiceItem(d) for d in data.get('invoice_items', [])] + + self.current_period_start: datetime = parse_time(data['subscription_period_start']) # type: ignore # Should always be a datetime + self.current_period_end: datetime = parse_time(data['subscription_period_end']) # type: ignore # Should always be a datetime + + def __repr__(self) -> str: + return f'' + + def is_discounted(self) -> bool: + """:class:`bool`: Indicates if the invoice has a discount.""" + return any(item.discounts for item in self.items) + + def is_preview(self) -> bool: + """:class:`bool`: Indicates if the invoice is a preview and not real.""" + return self.subscription is None or self.status is None + + async def pay( + self, + payment_source: Optional[Snowflake] = None, + currency: str = 'usd', + *, + payment_source_token: Optional[str] = None, + return_url: Optional[str] = None, + ) -> None: + """|coro| + + Pays the invoice. + + Parameters + ---------- + payment_source: Optional[:class:`PaymentSource`] + The payment source the invoice should be paid with. + currency: :class:`str` + The currency to pay with. + payment_source_token: Optional[:class:`str`] + The token used to authorize with the payment source. + return_url: Optional[:class:`str`] + The URL to return to after the payment is complete. + + Raises + ------ + TypeError + The invoice is a preview and not real. + NotFound + The invoice is not open or found. + HTTPException + Paying the invoice failed. + """ + if self.is_preview() or not self.subscription: + raise TypeError('Cannot pay a nonexistant invoice') + + data = await self._state.http.pay_invoice( + self.subscription.id, + self.id, + payment_source.id if payment_source else None, + payment_source_token, + currency, + return_url, + ) + self.subscription._update(data) + + +class SubscriptionRenewalMutations: + """Represents a subscription renewal mutation. + + This represents changes to a subscription that will occur after renewal. + + .. container:: operations + + .. describe:: len(x) + + Returns the number of items in the changed subscription, including quantity. + + .. describe:: bool(x) + + Returns whether any mutations are present. + + .. versionadded:: 2.0 + + Attributes + ---------- + payment_gateway_plan_id: Optional[:class:`str`] + The payment gateway's new plan ID for the subscription. + This signifies an external plan change. + items: Optional[List[:class:`SubscriptionItem`]] + The new items of the subscription. + """ + + __slots__ = ('payment_gateway_plan_id', 'items') + + def __init__(self, data: SubscriptionRenewalMutationsPayload) -> None: + self.payment_gateway_plan_id: Optional[str] = data.get('payment_gateway_plan_id') + self.items: Optional[List[SubscriptionItem]] = ( + [SubscriptionItem.from_dict(item) for item in data['items']] if 'items' in data else None + ) + + def __repr__(self) -> str: + return ( + f'' + ) + + def __len__(self) -> int: + return sum(item.quantity for item in self.items) if self.items else 0 + + def __bool__(self) -> bool: + return self.is_mutated() + + def is_mutated(self) -> bool: + """:class:`bool`: Checks if any renewal mutations exist.""" + return self.payment_gateway_plan_id is not None or self.items is not None + + +class Subscription(Hashable): + """Represents a Discord subscription. + + .. container:: operations + + .. describe:: x == y + + Checks if two premium subscriptions are equal. + + .. describe:: x != y + + Checks if two premium subscriptions are not equal. + + .. describe:: hash(x) + + Returns the subscription's hash. + + .. describe:: len(x) + + Returns the number of items in the subscription, including quantity. + + .. describe:: bool(x) + + Checks if the subscription is currently active and offering perks. + + .. versionadded:: 2.0 + + Attributes + ---------- + id: :class:`int` + The ID of the subscription. + type: :class:`SubscriptionType` + The type of the subscription. + status: Optional[:class:`SubscriptionStatus`] + The status of the subscription. This is ``None`` for fake subscriptions. + payment_gateway: Optional[:class:`PaymentGateway`] + The payment gateway used to bill the subscription. + currency: :class:`str` + The currency the subscription is billed in. + items: List[:class:`SubscriptionItem`] + The items in the subscription. + renewal_mutations: :class:`SubscriptionRenewalMutations` + The mutations to the subscription that will occur after renewal. + trial_id: Optional[:class:`int`] + The ID of the trial the subscription is from, if applicable. + payment_source_id: Optional[:class:`int`] + The ID of the payment source the subscription is paid with, if applicable. + payment_gateway_plan_id: Optional[:class:`str`] + The payment gateway's plan ID for the subscription, if applicable. + payment_gateway_subscription_id: Optional[:class:`str`] + The payment gateway's subscription ID for the subscription, if applicable. + created_at: :class:`datetime.datetime` + When the subscription was created. + canceled_at: Optional[:class:`datetime.datetime`] + When the subscription was canceled. + This is only available for subscriptions with a :attr:`status` of :attr:`SubscriptionStatus.canceled`. + current_period_start: :class:`datetime.datetime` + When the current billing period started. + current_period_end: :class:`datetime.datetime` + When the current billing period ends. + trial_ends_at: Optional[:class:`datetime.datetime`] + When the trial ends, if applicable. + streak_started_at: Optional[:class:`datetime.datetime`] + When the current subscription streak started. + ended_at: Optional[:class:`datetime.datetime`] + When the subscription finally ended. + metadata: :class:`Metadata` + Extra metadata about the subscription. + latest_invoice: Optional[:class:`SubscriptionInvoice`] + The latest invoice for the subscription, if applicable. + """ + + __slots__ = ( + '_state', + 'id', + 'type', + 'status', + 'payment_gateway', + 'currency', + 'items', + 'renewal_mutations', + 'trial_id', + 'payment_source_id', + 'payment_gateway_plan_id', + 'payment_gateway_subscription_id', + 'created_at', + 'canceled_at', + 'current_period_start', + 'current_period_end', + 'trial_ends_at', + 'streak_started_at', + 'ended_at', + 'metadata', + 'latest_invoice', + ) + + def __init__(self, *, data: Union[PartialSubscriptionPayload, SubscriptionPayload], state: ConnectionState) -> None: + self._state = state + self._update(data) + + def __repr__(self) -> str: + return f'' + + def __len__(self) -> int: + return sum(item.quantity for item in self.items) + + def __bool__(self) -> bool: + return self.is_active() + + def _update(self, data: PartialSubscriptionPayload) -> None: + self.id: int = int(data['id']) + self.type: SubscriptionType = try_enum(SubscriptionType, data['type']) + self.status: Optional[SubscriptionStatus] = ( + try_enum(SubscriptionStatus, data['status']) if 'status' in data else None # type: ignore # ??? + ) + self.payment_gateway: Optional[PaymentGateway] = ( + try_enum(PaymentGateway, data['payment_gateway']) if 'payment_gateway' in data else None + ) + self.currency: str = data.get('currency', 'usd') + self.items: List[SubscriptionItem] = [SubscriptionItem.from_dict(item) for item in data.get('items', [])] + self.renewal_mutations: SubscriptionRenewalMutations = SubscriptionRenewalMutations( + data.get('renewal_mutations') or {} + ) + + self.trial_id: Optional[int] = _get_as_snowflake(data, 'trial_id') + self.payment_source_id: Optional[int] = _get_as_snowflake(data, 'payment_source_id') + self.payment_gateway_plan_id: Optional[str] = data.get('payment_gateway_plan_id') + self.payment_gateway_subscription_id: Optional[str] = data.get('payment_gateway_subscription_id') + + self.created_at: datetime = parse_time(data.get('created_at')) or snowflake_time(self.id) + self.canceled_at: Optional[datetime] = parse_time(data.get('canceled_at')) + + self.current_period_start: datetime = parse_time(data['current_period_start']) + self.current_period_end: datetime = parse_time(data['current_period_end']) + self.trial_ends_at: Optional[datetime] = parse_time(data.get('trial_ends_at')) + self.streak_started_at: Optional[datetime] = parse_time(data.get('streak_started_at')) + + metadata = data.get('metadata') or {} + self.ended_at: Optional[datetime] = parse_time(metadata.get('ended_at', None)) + self.metadata: Metadata = Metadata(metadata) + + self.latest_invoice: Optional[SubscriptionInvoice] = ( + SubscriptionInvoice(self, data=data['latest_invoice'], state=self._state) if 'latest_invoice' in data else None # type: ignore # ??? + ) + + @property + def cancelled_at(self) -> Optional[datetime]: + """Optional[:class:`datetime.datetime`]: When the subscription was canceled. + This is only available for subscriptions with a :attr:`status` of :attr:`SubscriptionStatus.canceled`. + + This is an alias of :attr:`canceled_at`. + """ + return self.canceled_at + + @property + def guild(self) -> Optional[Guild]: + """:class:`Guild`: The guild the subscription's entitlements apply to, if applicable.""" + return self._state._get_guild(self.metadata.guild_id) + + @property + def grace_period(self) -> int: + """:class:`int`: How many days past the renewal date the user has available to pay outstanding invoices. + + .. note:: + + This is a static value and does not change based on the subscription's status. + For that, see :attr:`remaining`. + """ + return 7 if self.payment_source_id else 3 + + @property + def remaining(self) -> timedelta: + """:class:`datetime.timedelta`: The remaining time until the subscription ends.""" + if self.status in (SubscriptionStatus.active, SubscriptionStatus.cancelled): + return self.current_period_end - utcnow() + elif self.status == SubscriptionStatus.past_due: + if self.payment_gateway == PaymentGateway.google and self.metadata.google_grace_period_expires_date: + return self.metadata.google_grace_period_expires_date - utcnow() + return (self.current_period_start + timedelta(days=self.grace_period)) - utcnow() + elif self.status == SubscriptionStatus.account_hold: + # Max hold time is 30 days + return (self.current_period_start + timedelta(days=30)) - utcnow() + return timedelta() + + @property + def trial_remaining(self) -> timedelta: + """:class:`datetime.timedelta`: The remaining time until the trial applied to the subscription ends.""" + if not self.trial_id: + return timedelta() + if not self.trial_ends_at: + # Infinite trial? + return self.remaining + return self.trial_ends_at - utcnow() + + def is_active(self) -> bool: + """:class:`bool`: Indicates if the subscription is currently active and providing perks.""" + return self.remaining > timedelta() + + def is_trial(self) -> bool: + """:class:`bool`: Indicates if the subscription is a trial.""" + return self.trial_id is not None + + async def edit( + self, + items: List[SubscriptionItem] = MISSING, + payment_source: Snowflake = MISSING, + currency: str = MISSING, + *, + status: SubscriptionStatus = MISSING, + payment_source_token: Optional[str] = None, + ) -> None: + """|coro| + + Edits the subscription. + + All parameters are optional. + + Parameters + ---------- + items: List[:class:`SubscriptionItem`] + The new subscription items to use. + payment_source: :class:`int` + The new payment source for payment. + currency: :class:`str` + The new currency to use for payment. + status: :class:`SubscriptionStatus` + The new status of the subscription. + payment_source_token: Optional[:class:`str`] + The token used to authorize with the payment source. + + Raises + ------ + Forbidden + You do not have permissions to edit the subscription. + HTTPException + Editing the subscription failed. + """ + payload = {} + if items is not MISSING: + payload['items'] = [item.to_dict() for item in items] if items else [] + if payment_source is not MISSING: + payload['payment_source_id'] = payment_source.id + payload['payment_source_token'] = payment_source_token + if currency is not MISSING: + payload['currency'] = currency + if status is not MISSING: + payload['status'] = int(status) + + data = await self._state.http.edit_subscription(self.id, **payload) + self._update(data) + + async def delete(self) -> None: + """|coro| + + Deletes the subscription. + + There is an alias of this called :meth:`cancel`. + + Raises + ------ + HTTPException + Deleting the subscription failed. + """ + await self._state.http.delete_subscription(self.id) + + async def cancel(self) -> None: + """|coro| + + Deletes the subscription. + + Alias of :meth:`delete`. + + Raises + ------ + HTTPException + Deleting the subscription failed. + """ + await self.delete() + + async def preview_invoice( + self, + *, + items: List[SubscriptionItem] = MISSING, + payment_source: Snowflake = MISSING, + currency: str = MISSING, + apply_entitlements: bool = MISSING, + renewal: bool = MISSING, + ) -> SubscriptionInvoice: + """|coro| + + Preview an invoice for the subscription with the given parameters. + + All parameters are optional and default to the current subscription values. + + Parameters + ---------- + items: List[:class:`SubscriptionItem`] + The items the previewed invoice should have. + payment_source: :class:`.PaymentSource` + The payment source the previewed invoice should be paid with. + currency: :class:`str` + The currency the previewed invoice should be paid in. + apply_entitlements: :class:`bool` + Whether to apply entitlements (credits) to the previewed invoice. + renewal: :class:`bool` + Whether the previewed invoice should be a renewal. + + Raises + ------ + HTTPException + Failed to preview the invoice. + + Returns + ------- + :class:`SubscriptionInvoice` + The previewed invoice. + """ + payload: Dict[str, Any] = {} + if items is not MISSING: + payload['items'] = [item.to_dict() for item in items] if items else [] + if payment_source: + payload['payment_source_id'] = payment_source.id + if currency: + payload['currency'] = currency + if apply_entitlements is not MISSING: + payload['apply_entitlements'] = apply_entitlements + if renewal is not MISSING: + payload['renewal'] = renewal + + if payload: + data = await self._state.http.preview_subscription_update(self.id, **payload) + else: + data = await self._state.http.get_subscription_preview(self.id) + + return SubscriptionInvoice(self, data=data, state=self._state) + + async def payment_source(self) -> Optional[PaymentSource]: + """|coro| + + Retrieves the payment source the subscription is paid with, if applicable. + + Raises + ------ + NotFound + The payment source could not be found. + HTTPException + Retrieving the payment source failed. + + Returns + ------- + Optional[:class:`PaymentSource`] + The payment source the subscription is paid with, if applicable. + """ + if not self.payment_source_id: + return + + data = await self._state.http.get_payment_source(self.payment_source_id) + return PaymentSource(data=data, state=self._state) + + async def invoices(self): + """|coro| + + Retrieves all invoices for the subscription. + + Raises + ------ + NotFound + The payment source or invoices could not be found. + HTTPException + Retrieving the invoices failed. + + Returns + ------- + List[:class:`SubscriptionInvoice`] + The invoices. + """ + state = self._state + data = await state.http.get_subscription_invoices(self.id) + return [SubscriptionInvoice(self, data=d, state=state) for d in data] + + +class SubscriptionTrial(Hashable): + """Represents a subscription trial. + + .. container:: operations + + .. describe:: x == y + + Checks if two trials are equal. + + .. describe:: x != y + + Checks if two trials are not equal. + + .. describe:: hash(x) + + Returns the trial's hash. + + .. versionadded:: 2.0 + + Attributes + ---------- + id: :class:`int` + The ID of the trial. + interval: :class:`SubscriptionInterval` + The interval of the trial. + interval_count: :class:`int` + How many counts of the interval the trial provides. + """ + + __slots__ = ('id', 'interval', 'interval_count', 'sku_id') + + _INTERVAL_TABLE = { + SubscriptionInterval.day: 1, + SubscriptionInterval.month: 30, + SubscriptionInterval.year: 365, + } + + def __init__(self, data: SubscriptionTrialPayload): + self.id: int = int(data['id']) + self.interval: SubscriptionInterval = try_enum(SubscriptionInterval, data['interval']) + self.interval_count: int = data['interval_count'] + self.sku_id: int = int(data['sku_id']) + + def __repr__(self) -> str: + return ( + f'' + ) + + @property + def duration(self) -> timedelta: + """:class:`datetime.timedelta`: How long the trial lasts.""" + return timedelta(days=self.interval_count * self._INTERVAL_TABLE[self.interval]) diff --git a/discord/team.py b/discord/team.py index 530fe2792..0e70a6d06 100644 --- a/discord/team.py +++ b/discord/team.py @@ -24,31 +24,34 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations +from datetime import datetime +from typing import TYPE_CHECKING, AsyncIterator, List, Optional, Union, overload + from . import utils from .asset import Asset -from .enums import TeamMembershipState, try_enum +from .enums import ApplicationMembershipState, PayoutAccountStatus, PayoutReportType, PayoutStatus, try_enum +from .metadata import Metadata from .mixins import Hashable -from .user import BaseUser - -from typing import TYPE_CHECKING, Optional, overload, List, Union +from .object import Object +from .user import User, _UserTag if TYPE_CHECKING: - from .abc import Snowflake - from .state import ConnectionState - - from .types.team import ( - Team as TeamPayload, - TeamMember as TeamMemberPayload, - ) - from .types.user import User as UserPayload + from datetime import date -MISSING = utils.MISSING + from .abc import Snowflake, SnowflakeTime + from .appinfo import Application, Company + from .state import ConnectionState + from .types.team import Team as TeamPayload, TeamMember as TeamMemberPayload, TeamPayout as TeamPayoutPayload + from .types.user import PartialUser as PartialUserPayload __all__ = ( 'Team', 'TeamMember', + 'TeamPayout', ) +MISSING = utils.MISSING + class Team(Hashable): """Represents an application team. @@ -71,6 +74,8 @@ class Team(Hashable): Returns the team's name. + .. versionadded:: 2.0 + Attributes ------------- id: :class:`int` @@ -80,18 +85,36 @@ class Team(Hashable): owner_id: :class:`int` The team's owner ID. members: List[:class:`TeamMember`] - A list of the members in the team. - A call to :meth:`fetch_members` may be required to populate this past the owner. - """ + The team's members. + + .. note:: - if TYPE_CHECKING: - owner_id: int - members: List[TeamMember] + In almost all cases, a call to :meth:`fetch_members` + is required to populate this list past (sometimes) the owner. + payout_account_status: Optional[:class:`PayoutAccountStatus`] + The team's payout account status, if any and available. + stripe_connect_account_id: Optional[:class:`str`] + The account ID representing the Stripe Connect account the + team's payout account is linked to, if any and available. + """ - __slots__ = ('_state', 'id', 'name', '_icon', 'owner_id', 'members') + __slots__ = ( + '_state', + 'id', + 'name', + '_icon', + 'owner_id', + 'members', + 'payout_account_status', + 'stripe_connect_account_id', + ) def __init__(self, state: ConnectionState, data: TeamPayload): self._state: ConnectionState = state + + self.members: List[TeamMember] = [] + self.payout_account_status: Optional[PayoutAccountStatus] = None + self.stripe_connect_account_id: Optional[str] = None self._update(data) def __repr__(self) -> str: @@ -101,20 +124,32 @@ class Team(Hashable): return self.name def _update(self, data: TeamPayload): + state = self._state + self.id: int = int(data['id']) self.name: str = data['name'] self._icon: Optional[str] = data['icon'] self.owner_id = owner_id = int(data['owner_user_id']) - self.members = members = [TeamMember(self, self._state, member) for member in data.get('members', [])] - if owner_id not in members and owner_id == self._state.self_id: # Discord moment - user: UserPayload = self._state.user._to_minimal_user_json() # type: ignore - member: TeamMemberPayload = { - 'user': user, - 'team_id': self.id, - 'membership_state': 2, - 'permissions': ['*'], - } - members.append(TeamMember(self, self._state, member)) + + if 'members' in data: + self.members = [TeamMember(self, state=state, data=member) for member in data.get('members', [])] + + if not self.owner: + owner = self._state.get_user(owner_id) + if owner: + user: PartialUserPayload = owner._to_minimal_user_json() + member: TeamMemberPayload = { + 'user': user, + 'team_id': self.id, + 'membership_state': 2, + 'permissions': ['*'], + } + self.members.append(TeamMember(self, self._state, member)) + + if 'payout_account_status' in data: + self.payout_account_status = try_enum(PayoutAccountStatus, data.get('payout_account_status')) + if 'stripe_connect_account_id' in data: + self.stripe_connect_account_id = data.get('stripe_connect_account_id') @property def icon(self) -> Optional[Asset]: @@ -123,9 +158,22 @@ class Team(Hashable): return None return Asset._from_icon(self._state, self.id, self._icon, path='team') + @property + def default_icon(self) -> Asset: + """:class:`Asset`: Returns the default icon for the team. This is calculated by the team's ID.""" + return Asset._from_default_avatar(self._state, int(self.id) % 5) + + @property + def display_icon(self) -> Asset: + """:class:`Asset`: Returns the team's display icon. + + For regular teams this is just their default icon or uploaded icon. + """ + return self.icon or self.default_icon + @property def owner(self) -> Optional[TeamMember]: - """Optional[:class:`TeamMember`]: The team's owner.""" + """Optional[:class:`TeamMember`]: The team's owner, if available.""" return utils.get(self.members, id=self.owner_id) async def edit( @@ -139,13 +187,15 @@ class Team(Hashable): Edits the team. + All parameters are optional. + Parameters ----------- name: :class:`str` The name of the team. icon: Optional[:class:`bytes`] The icon of the team. - owner: :class:`~abc.Snowflake` + owner: :class:`User` The team's owner. Raises @@ -169,10 +219,33 @@ class Team(Hashable): data = await self._state.http.edit_team(self.id, payload) self._update(data) + async def applications(self) -> List[Application]: + """|coro| + + Retrieves the team's applications. + + Returns + -------- + List[:class:`TeamMember`] + The team's applications. + + Raises + ------- + Forbidden + You do not have permissions to fetch the team's applications. + HTTPException + Retrieving the team applications failed. + """ + from .appinfo import Application + + state = self._state + data = await state.http.get_team_applications(self.id) + return [Application(state=state, data=app, team=self) for app in data] + async def fetch_members(self) -> List[TeamMember]: """|coro| - Retrieves the team's members. + Retrieves and caches the team's members. Returns -------- @@ -192,7 +265,7 @@ class Team(Hashable): return members @overload - async def invite_member(self, user: BaseUser, /) -> TeamMember: + async def invite_member(self, user: _UserTag, /) -> TeamMember: ... @overload @@ -203,7 +276,7 @@ class Team(Hashable): async def invite_member(self, username: str, discriminator: str, /) -> TeamMember: ... - async def invite_member(self, *args: Union[BaseUser, str]) -> TeamMember: + async def invite_member(self, *args: Union[_UserTag, str]) -> TeamMember: """|coro| Invites a member to the team. @@ -241,14 +314,14 @@ class Team(Hashable): Returns ------- - :class:`.TeamMember` + :class:`TeamMember` The new member. """ username: str discrim: str if len(args) == 1: user = args[0] - if isinstance(user, BaseUser): + if isinstance(user, _UserTag): user = str(user) username, discrim = user.split('#') elif len(args) == 2: @@ -262,6 +335,112 @@ class Team(Hashable): self.members.append(member) return member + async def create_company(self, name: str, /) -> Company: + """|coro| + + Creates a company for the team. + + Parameters + ----------- + name: :class:`str` + The name of the company. + + Raises + ------- + Forbidden + You do not have permissions to create a company. + HTTPException + Creating the company failed. + + Returns + ------- + :class:`.Company` + The created company. + """ + from .appinfo import Company + + state = self._state + data = await state.http.create_team_company(self.id, name) + return Company(data=data) + + async def payouts( + self, + *, + limit: Optional[int] = 96, + before: Optional[SnowflakeTime] = None, + ) -> AsyncIterator[TeamPayout]: + """Returns an :term:`asynchronous iterator` that enables receiving your team payouts. + + .. versionadded:: 2.0 + + Examples + --------- + + Usage :: + + total = 0 + async for payout in team.payouts(): + if payout.period_end: + total += payout.amount + + Flattening into a list: :: + + payments = [payout async for payout in team.payouts(limit=123)] + # payments is now a list of TeamPayout... + + All parameters are optional. + + Parameters + ----------- + limit: Optional[:class:`int`] + The number of payouts to retrieve. + If ``None``, retrieves every payout you have. Note, however, + that this would make it a slow operation. + before: Optional[Union[:class:`~discord.abc.Snowflake`, :class:`datetime.datetime`]] + Retrieve payments before this date or payout. + 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 + The request to get team payouts failed. + + Yields + ------- + :class:`~discord.TeamPayout` + The payout received. + """ + + async def strategy(retrieve, before, limit): + before_id = before.id if before else None + data = await self._state.http.get_team_payouts(self.id, limit=retrieve, before=before_id) + + if data: + if limit is not None: + limit -= len(data) + + before = Object(id=int(data[-1]['id'])) + + return data, before, limit + + if isinstance(before, datetime): + before = Object(id=utils.time_snowflake(before, high=False)) + + while True: + retrieve = min(96 if limit is None else limit, 100) + if retrieve < 1: + return + + data, before, limit = await strategy(retrieve, before, limit) + + # Terminate loop on next iteration; there's no data left after this + if len(data) < 96: + limit = 0 + + for payout in data: + yield TeamPayout(data=payout, team=self) + async def delete(self) -> None: """|coro| @@ -277,7 +456,7 @@ class Team(Hashable): await self._state.http.delete_team(self.id) -class TeamMember(BaseUser): +class TeamMember(User): """Represents a team member in a team. .. container:: operations @@ -304,16 +483,18 @@ class TeamMember(BaseUser): ------------- team: :class:`Team` The team that the member is from. - membership_state: :class:`TeamMembershipState` + membership_state: :class:`ApplicationMembershipState` The membership state of the member (i.e. invited or accepted) + permissions: List[:class:`str`] + The permissions of the team member. This is always "*". """ __slots__ = ('team', 'membership_state', 'permissions') def __init__(self, team: Team, state: ConnectionState, data: TeamMemberPayload): self.team: Team = team - self.membership_state: TeamMembershipState = try_enum(TeamMembershipState, data['membership_state']) - self.permissions: List[str] = data['permissions'] + self.membership_state: ApplicationMembershipState = try_enum(ApplicationMembershipState, data['membership_state']) + self.permissions: List[str] = data.get('permissions', ['*']) super().__init__(state=state, data=data['user']) def __repr__(self) -> str: @@ -335,3 +516,84 @@ class TeamMember(BaseUser): Removing the member failed. """ await self._state.http.remove_team_member(self.team.id, self.id) + + +class TeamPayout(Hashable): + """Represents a team payout. + + .. container:: operations + + .. describe:: x == y + + Checks if two team payouts are equal. + + .. describe:: x != y + + Checks if two team payouts are not equal. + + .. describe:: hash(x) + + Return the team payout's hash. + + .. versionadded:: 2.0 + + Attributes + ----------- + id: :class:`int` + The ID of the payout. + user_id: :class:`int` + The ID of the user who is to be receiving the payout. + status: :class:`PayoutStatus` + The status of the payout. + amount: :class:`int` + The amount of the payout. + period_start: :class:`datetime.date` + The start of the payout period. + period_end: Optional[:class:`datetime.date`] + The end of the payout period, if ended. + payout_date: Optional[:class:`datetime.date`] + The date the payout was made, if made. + tipalti_submission_response: Optional[:class:`Metadata`] + The latest response from Tipalti, if exists. + """ + + def __init__(self, *, data: TeamPayoutPayload, team: Team): + self.team: Team = team + + self.id: int = int(data['id']) + self.user_id: int = int(data['user_id']) + self.status: PayoutStatus = try_enum(PayoutStatus, data['status']) + self.amount: int = data['amount'] + self.period_start: date = utils.parse_date(data['period_start']) + self.period_end: Optional[date] = utils.parse_date(data.get('period_end')) + self.payout_date: Optional[date] = utils.parse_date(data.get('payout_date')) + self.tipalti_submission_response: Optional[Metadata] = ( + Metadata(data['latest_tipalti_submission_response']) if 'latest_tipalti_submission_response' in data else None + ) + + def __repr__(self) -> str: + return f'<{self.__class__.__name__} id={self.id} status={self.status!r}>' + + async def report(self, type: PayoutReportType) -> bytes: + """|coro| + + Returns the report for the payout in CSV format. + + Parameters + ----------- + type: :class:`PayoutReportType` + The type of report to get the URL for. + + Raises + ------- + Forbidden + You do not have permissions to get the report URL. + HTTPException + Getting the report URL failed. + + Returns + ------- + :class:`bytes` + The report content. + """ + return await self.team._state.http.get_team_payout_report(self.team.id, self.id, str(type)) diff --git a/discord/tracking.py b/discord/tracking.py index c45ac2934..1229e0da9 100644 --- a/discord/tracking.py +++ b/discord/tracking.py @@ -88,6 +88,7 @@ class ContextProperties: # Thank you Discord-S.C.U.M # Locations 'Friends': 'eyJsb2NhdGlvbiI6IkZyaWVuZHMifQ==', 'ContextMenu': 'eyJsb2NhdGlvbiI6IkNvbnRleHRNZW51In0=', + 'Context Menu': 'eyJsb2NhdGlvbiI6IkNvbnRleHQgTWVudSJ9', 'User Profile': 'eyJsb2NhdGlvbiI6IlVzZXIgUHJvZmlsZSJ9', 'Add Friend': 'eyJsb2NhdGlvbiI6IkFkZCBGcmllbmQifQ==', 'Guild Header': 'eyJsb2NhdGlvbiI6Ikd1aWxkIEhlYWRlciJ9', @@ -100,6 +101,7 @@ class ContextProperties: # Thank you Discord-S.C.U.M 'New Group DM': 'eyJsb2NhdGlvbiI6Ik5ldyBHcm91cCBETSJ9', 'Add Friends to DM': 'eyJsb2NhdGlvbiI6IkFkZCBGcmllbmRzIHRvIERNIn0=', 'Group DM Invite Create': 'eyJsb2NhdGlvbiI6Ikdyb3VwIERNIEludml0ZSBDcmVhdGUifQ==', + 'Stage Channel': 'eyJsb2NhdGlvbiI6IlN0YWdlIENoYW5uZWwifQ==', # Sources 'Chat Input Blocker - Lurker Mode': 'eyJzb3VyY2UiOiJDaGF0IElucHV0IEJsb2NrZXIgLSBMdXJrZXIgTW9kZSJ9', 'Notice - Lurker Mode': 'eyJzb3VyY2UiOiJOb3RpY2UgLSBMdXJrZXIgTW9kZSJ9', @@ -120,10 +122,15 @@ class ContextProperties: # Thank you Discord-S.C.U.M return cls(data) @classmethod - def _from_context_menu(cls) -> Self: + def _from_contextmenu(cls) -> Self: data = {'location': 'ContextMenu'} return cls(data) + @classmethod + def _from_context_menu(cls) -> Self: + data = {'location': 'Context Menu'} + return cls(data) + @classmethod def _from_user_profile(cls) -> Self: data = {'location': 'User Profile'} @@ -135,7 +142,7 @@ class ContextProperties: # Thank you Discord-S.C.U.M return cls(data) @classmethod - def _from_guild_header_menu(cls) -> Self: + def _from_guild_header(cls) -> Self: data = {'location': 'Guild Header'} return cls(data) @@ -184,6 +191,11 @@ class ContextProperties: # Thank you Discord-S.C.U.M data = {'location': 'Verify Email'} return cls(data) + @classmethod + def _from_stage_channel(cls) -> Self: + data = {'location': 'Stage Channel'} + return cls(data) + @classmethod def _from_accept_invite_page( cls, diff --git a/discord/types/appinfo.py b/discord/types/appinfo.py index 01ea16dda..271097c25 100644 --- a/discord/types/appinfo.py +++ b/discord/types/appinfo.py @@ -24,49 +24,204 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations -from typing import TypedDict, List, Optional +from typing import Dict, List, Literal, Optional, TypedDict, Union from typing_extensions import NotRequired -from .user import User -from .team import Team +from .guild import PartialGuild from .snowflake import Snowflake +from .team import Team +from .user import PartialUser -class BaseAppInfo(TypedDict): +class BaseApplication(TypedDict): id: Snowflake name: str - verify_key: str - icon: Optional[str] - summary: str description: str - cover_image: Optional[str] - flags: NotRequired[int] - rpc_origins: List[str] + icon: Optional[str] + cover_image: NotRequired[Optional[str]] + type: Optional[int] + primary_sku_id: NotRequired[Snowflake] + summary: NotRequired[Literal['']] + +class IntegrationApplication(BaseApplication): + bot: NotRequired[PartialUser] + role_connections_verification_url: NotRequired[Optional[str]] -class AppInfo(BaseAppInfo): - owner: User + +class PartialApplication(BaseApplication): + owner: NotRequired[PartialUser] # Not actually ever present in partial app + team: NotRequired[Team] + verify_key: str + description: str + cover_image: NotRequired[Optional[str]] + flags: NotRequired[int] + rpc_origins: NotRequired[List[str]] + hook: NotRequired[bool] + overlay: NotRequired[bool] + overlay_compatibility_hook: NotRequired[bool] + terms_of_service_url: NotRequired[str] + privacy_policy_url: NotRequired[str] + max_participants: NotRequired[Optional[int]] bot_public: NotRequired[bool] bot_require_code_grant: NotRequired[bool] integration_public: NotRequired[bool] integration_require_code_grant: NotRequired[bool] - team: NotRequired[Team] guild_id: NotRequired[Snowflake] primary_sku_id: NotRequired[Snowflake] slug: NotRequired[str] - terms_of_service_url: NotRequired[str] - privacy_policy_url: NotRequired[str] - hook: NotRequired[bool] - max_participants: NotRequired[int] - interactions_endpoint_url: NotRequired[str] + developers: NotRequired[List[Company]] + publishers: NotRequired[List[Company]] + aliases: NotRequired[List[str]] + eula_id: NotRequired[Snowflake] + embedded_activity_config: NotRequired[EmbeddedActivityConfig] + guild: NotRequired[PartialGuild] + + +class ApplicationDiscoverability(TypedDict): + discoverability_state: int + discovery_eligibility_flags: int + + +class Application(PartialApplication, IntegrationApplication, ApplicationDiscoverability): + redirect_uris: List[str] + interactions_endpoint_url: Optional[str] verification_state: int store_application_state: int rpc_application_state: int - interactions_endpoint_url: str + creator_monetization_state: int + role_connections_verification_url: NotRequired[Optional[str]] + + +class WhitelistedUser(TypedDict): + user: PartialUser + state: Literal[1, 2] + + +class Asset(TypedDict): + id: Snowflake + name: str + type: int + + +class StoreAsset(TypedDict): + id: Snowflake + size: int + width: int + height: int + mime_type: str + + +class Company(TypedDict): + id: Snowflake + name: str + + +class EULA(TypedDict): + id: Snowflake + name: str + content: str + + +class BaseAchievement(TypedDict): + id: Snowflake + name: Union[str, Dict[str, Union[str, Dict[str, str]]]] + name_localizations: NotRequired[Dict[str, str]] + description: Union[str, Dict[str, Union[str, Dict[str, str]]]] + description_localizations: NotRequired[Dict[str, str]] + icon_hash: str + secure: bool + secret: bool + + +class Achievement(BaseAchievement): + application_id: Snowflake + + +class Ticket(TypedDict): + ticket: str + + +class Branch(TypedDict): + id: Snowflake + live_build_id: NotRequired[Optional[Snowflake]] + created_at: NotRequired[str] + name: NotRequired[str] + + +class BranchSize(TypedDict): + size_kb: str # Stringified float + + +class DownloadSignature(TypedDict): + endpoint: str + expires: int + signature: str + + +class Build(TypedDict): + application_id: NotRequired[Snowflake] + created_at: NotRequired[str] + id: Snowflake + manifests: List[Manifest] + status: Literal['CORRUPTED', 'INVALID', 'READY', 'VALIDATING', 'UPLOADED', 'UPLOADING', 'CREATED'] + source_build_id: NotRequired[Optional[Snowflake]] + version: NotRequired[Optional[str]] + + +class CreatedBuild(TypedDict): + build: Build + manifest_uploads: List[Manifest] + + +class BuildFile(TypedDict): + id: Snowflake + md5_hash: NotRequired[str] + + +class CreatedBuildFile(TypedDict): + id: str + url: str + + +class ManifestLabel(TypedDict): + application_id: Snowflake + id: Snowflake + name: NotRequired[str] + + +class Manifest(TypedDict): + id: Snowflake + label: ManifestLabel + redistributable_label_ids: NotRequired[List[Snowflake]] + url: Optional[str] + + +class ActivityStatistics(TypedDict): + application_id: NotRequired[Snowflake] + user_id: NotRequired[Snowflake] + total_duration: int + total_discord_sku_duration: NotRequired[int] + last_played_at: str + + +class GlobalActivityStatistics(TypedDict): + application_id: Snowflake + user_id: Snowflake + duration: int + updated_at: str + + +class EmbeddedActivityConfig(TypedDict): + supported_platforms: List[Literal['web', 'android', 'ios']] + default_orientation_lock_state: Literal[1, 2, 3] + activity_preview_video_asset_id: NotRequired[Optional[Snowflake]] + + +class ActiveDeveloperWebhook(TypedDict): + channel_id: Snowflake + webhook_id: Snowflake -class PartialAppInfo(BaseAppInfo, total=False): - hook: bool - terms_of_service_url: str - privacy_policy_url: str - max_participants: int +class ActiveDeveloperResponse(TypedDict): + follower: ActiveDeveloperWebhook diff --git a/discord/types/billing.py b/discord/types/billing.py new file mode 100644 index 000000000..3eb673678 --- /dev/null +++ b/discord/types/billing.py @@ -0,0 +1,78 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Dolfies + +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 Literal, TypedDict +from typing_extensions import NotRequired + + +class BillingAddress(TypedDict): + line_1: str + line_2: NotRequired[str] + name: str + postal_code: NotRequired[str] + city: str + state: NotRequired[str] + country: str + email: NotRequired[str] + + +class BillingAddressToken(TypedDict): + token: str + + +class PartialPaymentSource(TypedDict): + id: str + brand: NotRequired[str] + country: NotRequired[str] + last_4: NotRequired[str] + type: Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16] + payment_gateway: Literal[1, 2, 3, 4, 5, 6] + invalid: bool + flags: int + expires_month: NotRequired[int] + expires_year: NotRequired[int] + email: NotRequired[str] + bank: NotRequired[str] + username: NotRequired[str] + screen_status: int # TODO: Figure this out + + +class PaymentSource(PartialPaymentSource): + billing_address: BillingAddress + default: bool + + +class PremiumUsageValue(TypedDict): + value: int + + +class PremiumUsage(TypedDict): + nitro_sticker_sends: PremiumUsageValue + total_animated_emojis: PremiumUsageValue + total_global_emojis: PremiumUsageValue + total_large_uploads: PremiumUsageValue + total_hd_streams: PremiumUsageValue + hd_hours_streamed: PremiumUsageValue diff --git a/discord/types/channel.py b/discord/types/channel.py index 00e34d4f7..84df14ca7 100644 --- a/discord/types/channel.py +++ b/discord/types/channel.py @@ -57,8 +57,14 @@ class _BaseGuildChannel(_BaseChannel): parent_id: Optional[Snowflake] +class PartialRecipient(TypedDict): + username: str + + class PartialChannel(_BaseChannel): type: ChannelType + icon: NotRequired[Optional[str]] + recipients: NotRequired[List[PartialRecipient]] class _BaseTextChannel(_BaseGuildChannel, total=False): diff --git a/discord/types/entitlements.py b/discord/types/entitlements.py new file mode 100644 index 000000000..2f07071a3 --- /dev/null +++ b/discord/types/entitlements.py @@ -0,0 +1,95 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Dolfies + +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 typing_extensions import NotRequired + +from .payments import PartialPayment +from .promotions import Promotion +from .snowflake import Snowflake +from .store import SKU, StoreListing +from .subscriptions import PartialSubscriptionPlan, SubscriptionPlan, SubscriptionTrial +from .user import PartialUser + + +class Entitlement(TypedDict): + id: Snowflake + type: Literal[1, 2, 3, 4, 5, 6, 7] + user_id: Snowflake + sku_id: Snowflake + application_id: Snowflake + promotion_id: Optional[Snowflake] + parent_id: NotRequired[Snowflake] + guild_id: NotRequired[Snowflake] + branches: NotRequired[List[Snowflake]] + gifter_user_id: NotRequired[Snowflake] + gift_style: NotRequired[Literal[1, 2, 3]] + gift_batch_id: NotRequired[Snowflake] + gift_code_flags: NotRequired[int] + deleted: bool + consumed: NotRequired[bool] + starts_at: NotRequired[str] + ends_at: NotRequired[str] + subscription_id: NotRequired[Snowflake] + subscription_plan: NotRequired[PartialSubscriptionPlan] + sku: NotRequired[SKU] + payment: NotRequired[PartialPayment] + + +class GatewayGift(TypedDict): + code: str + uses: int + sku_id: Snowflake + channel_id: NotRequired[Snowflake] + guild_id: NotRequired[Snowflake] + + +class Gift(GatewayGift): + expires_at: Optional[str] + application_id: Snowflake + batch_id: NotRequired[Snowflake] + entitlement_branches: NotRequired[List[Snowflake]] + gift_style: NotRequired[Optional[Literal[1, 2, 3]]] + flags: int + max_uses: int + uses: int + redeemed: bool + revoked: NotRequired[bool] + store_listing: NotRequired[StoreListing] + promotion: NotRequired[Promotion] + subscription_trial: NotRequired[SubscriptionTrial] + subscription_plan: NotRequired[SubscriptionPlan] + user: NotRequired[PartialUser] + + +class GiftBatch(TypedDict): + id: Snowflake + sku_id: Snowflake + amount: int + description: NotRequired[str] + entitlement_branches: NotRequired[List[Snowflake]] + entitlement_starts_at: NotRequired[str] + entitlement_ends_at: NotRequired[str] diff --git a/discord/types/gateway.py b/discord/types/gateway.py index 262b5510c..f1b64328d 100644 --- a/discord/types/gateway.py +++ b/discord/types/gateway.py @@ -37,12 +37,16 @@ from .member import MemberWithUser from .snowflake import Snowflake from .message import Message from .sticker import GuildSticker -from .appinfo import PartialAppInfo +from .appinfo import BaseAchievement, PartialApplication from .guild import Guild, UnavailableGuild, SupplementalGuild -from .user import Connection, User +from .user import Connection, User, PartialUser from .threads import Thread, ThreadMember from .scheduled_event import GuildScheduledEvent from .channel import DMChannel, GroupDMChannel +from .subscriptions import PremiumGuildSubscriptionSlot +from .payments import Payment +from .entitlements import Entitlement, GatewayGift +from .library import LibraryApplication PresenceUpdateEvent = PartialPresenceUpdate @@ -57,8 +61,14 @@ class ShardInfo(TypedDict): shard_count: int -class ReadyEvent(TypedDict): +class ResumedEvent(TypedDict): + _trace: List[str] + + +class ReadyEvent(ResumedEvent): + api_code_version: int analytics_token: str + auth_session_id_hash: str auth_token: NotRequired[str] connected_accounts: List[Connection] country_code: str @@ -66,17 +76,19 @@ class ReadyEvent(TypedDict): geo_ordered_rtc_regions: List[str] guilds: List[Guild] merged_members: List[List[MemberWithUser]] + pending_payments: NotRequired[List[Payment]] private_channels: List[Union[DMChannel, GroupDMChannel]] relationships: List[dict] required_action: NotRequired[str] sessions: List[dict] session_id: str session_type: str + shard: NotRequired[ShardInfo] user: User user_guild_settings: dict - user_settings: dict + user_settings: NotRequired[dict] user_settings_proto: str - users: List[User] + users: List[PartialUser] v: int @@ -91,7 +103,7 @@ class ReadySupplementalEvent(TypedDict): merged_presences: MergedPresences -ResumedEvent = Literal[None] +NoEvent = Literal[None] MessageCreateEvent = Message @@ -157,10 +169,10 @@ class InviteCreateEvent(TypedDict): temporary: bool uses: Literal[0] guild_id: NotRequired[Snowflake] - inviter: NotRequired[User] + inviter: NotRequired[PartialUser] target_type: NotRequired[InviteTargetType] - target_user: NotRequired[User] - target_application: NotRequired[PartialAppInfo] + target_user: NotRequired[PartialUser] + target_application: NotRequired[PartialApplication] class InviteDeleteEvent(TypedDict): @@ -203,6 +215,7 @@ class ThreadListSyncEvent(TypedDict): threads: List[Thread] members: List[ThreadMember] channel_ids: NotRequired[List[Snowflake]] + most_recent_messages: List[Message] class ThreadMemberUpdate(ThreadMember): @@ -221,15 +234,19 @@ class GuildMemberAddEvent(MemberWithUser): guild_id: Snowflake +class SnowflakeUser(TypedDict): + id: Snowflake + + class GuildMemberRemoveEvent(TypedDict): guild_id: Snowflake - user: User + user: Union[PartialUser, SnowflakeUser] class GuildMemberUpdateEvent(TypedDict): guild_id: Snowflake roles: List[Snowflake] - user: User + user: PartialUser avatar: Optional[str] joined_at: Optional[str] nick: NotRequired[str] @@ -256,7 +273,7 @@ GuildDeleteEvent = UnavailableGuild class _GuildBanEvent(TypedDict): guild_id: Snowflake - user: User + user: PartialUser GuildBanAddEvent = GuildBanRemoveEvent = _GuildBanEvent @@ -341,3 +358,58 @@ class TypingStartEvent(TypedDict): timestamp: int guild_id: NotRequired[Snowflake] member: NotRequired[MemberWithUser] + + +ConnectionEvent = Connection + + +class PartialConnectionEvent(TypedDict): + user_id: Snowflake + + +class ConnectionsLinkCallbackEvent(TypedDict): + provider: str + callback_code: str + callback_state: str + + +class OAuth2TokenRevokeEvent(TypedDict): + access_token: str + + +class AuthSessionChangeEvent(TypedDict): + auth_session_id_hash: str + + +class PaymentClientAddEvent(TypedDict): + purchase_token_hash: str + expires_at: str + + +class AchievementUpdatePayload(TypedDict): + application_id: Snowflake + achievement: BaseAchievement + percent_complete: int + + +PremiumGuildSubscriptionSlotEvent = PremiumGuildSubscriptionSlot + + +class RequiredActionEvent(TypedDict): + required_action: str + + +class BillingPopupBridgeCallbackEvent(TypedDict): + payment_source_type: int + state: str + path: str + query: str + + +PaymentUpdateEvent = Payment + +GiftCreateEvent = GiftUpdateEvent = GatewayGift + +EntitlementEvent = Entitlement + +LibraryApplicationUpdateEvent = LibraryApplication diff --git a/discord/types/guild.py b/discord/types/guild.py index b6de3d161..9d918272f 100644 --- a/discord/types/guild.py +++ b/discord/types/guild.py @@ -57,7 +57,7 @@ NSFWLevel = Literal[0, 1, 2, 3] PremiumTier = Literal[0, 1, 2, 3] -class _BaseGuildPreview(UnavailableGuild): +class PartialGuild(UnavailableGuild): name: str icon: Optional[str] splash: Optional[str] @@ -73,11 +73,11 @@ class _GuildPreviewUnique(TypedDict): approximate_presence_count: int -class GuildPreview(_BaseGuildPreview, _GuildPreviewUnique): +class GuildPreview(PartialGuild, _GuildPreviewUnique): ... -class Guild(_BaseGuildPreview): +class Guild(PartialGuild): owner_id: Snowflake region: str afk_channel_id: Optional[Snowflake] diff --git a/discord/types/integration.py b/discord/types/integration.py index cefde2f18..bf1ccd4ae 100644 --- a/discord/types/integration.py +++ b/discord/types/integration.py @@ -24,23 +24,15 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations -from typing import Literal, Optional, TypedDict, Union +from typing import List, Literal, Optional, TypedDict, Union from typing_extensions import NotRequired +from .appinfo import IntegrationApplication from .guild import Guild from .snowflake import Snowflake from .user import User -class IntegrationApplication(TypedDict): - id: Snowflake - name: str - icon: Optional[str] - description: str - summary: str - bot: NotRequired[User] - - class IntegrationAccount(TypedDict): id: str name: str @@ -54,6 +46,7 @@ class PartialIntegration(TypedDict): name: str type: IntegrationType account: IntegrationAccount + application_id: NotRequired[Snowflake] IntegrationType = Literal['twitch', 'youtube', 'discord'] @@ -61,11 +54,7 @@ IntegrationType = Literal['twitch', 'youtube', 'discord'] class BaseIntegration(PartialIntegration): enabled: bool - syncing: bool - synced_at: str - user: User - expire_behavior: IntegrationExpireBehavior - expire_grace_period: int + user: NotRequired[User] class StreamIntegration(BaseIntegration): @@ -73,10 +62,15 @@ class StreamIntegration(BaseIntegration): enable_emoticons: bool subscriber_count: int revoked: bool + expire_behavior: IntegrationExpireBehavior + expire_grace_period: int + syncing: bool + synced_at: str class BotIntegration(BaseIntegration): application: IntegrationApplication + scopes: List[str] class ConnectionIntegration(BaseIntegration): diff --git a/discord/types/invite.py b/discord/types/invite.py index b53ca374c..7b807daf7 100644 --- a/discord/types/invite.py +++ b/discord/types/invite.py @@ -32,7 +32,7 @@ from .snowflake import Snowflake from .guild import InviteGuild, _GuildPreviewUnique from .channel import PartialChannel from .user import PartialUser -from .appinfo import PartialAppInfo +from .appinfo import PartialApplication InviteTargetType = Literal[1, 2] @@ -61,7 +61,7 @@ class Invite(IncompleteInvite, total=False): inviter: PartialUser target_user: PartialUser target_type: InviteTargetType - target_application: PartialAppInfo + target_application: PartialApplication guild_scheduled_event: GuildScheduledEvent @@ -81,7 +81,7 @@ class GatewayInviteCreate(TypedDict): inviter: NotRequired[PartialUser] target_type: NotRequired[InviteTargetType] target_user: NotRequired[PartialUser] - target_application: NotRequired[PartialAppInfo] + target_application: NotRequired[PartialApplication] class GatewayInviteDelete(TypedDict): diff --git a/discord/types/library.py b/discord/types/library.py new file mode 100644 index 000000000..f17d87b12 --- /dev/null +++ b/discord/types/library.py @@ -0,0 +1,44 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Dolfies + +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, TypedDict +from typing_extensions import NotRequired + +from .appinfo import Branch, PartialApplication +from .entitlements import Entitlement +from .snowflake import Snowflake +from .store import PartialSKU + + +class LibraryApplication(TypedDict): + created_at: str + application: PartialApplication + sku_id: Snowflake + sku: PartialSKU + entitlements: List[Entitlement] + flags: int + branch_id: Snowflake + branch: NotRequired[Branch] diff --git a/discord/types/payments.py b/discord/types/payments.py new file mode 100644 index 000000000..450767864 --- /dev/null +++ b/discord/types/payments.py @@ -0,0 +1,61 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Dolfies + +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, TypedDict +from typing_extensions import NotRequired + +from .billing import PartialPaymentSource +from .snowflake import Snowflake +from .store import SKU +from .subscriptions import PartialSubscription + + +class PartialPayment(TypedDict): + # TODO: There might be more, but I don't have an example payload + id: Snowflake + amount: int + tax: int + tax_inclusive: bool + currency: str + + +class Payment(PartialPayment): + amount_refunded: int + description: str + status: Literal[0, 1, 2, 3, 4, 5] + created_at: str + sku_id: NotRequired[Snowflake] + sku_price: NotRequired[int] + sku_subscription_plan_id: NotRequired[Snowflake] + payment_gateway: NotRequired[Literal[1, 2, 3, 4, 5, 6]] + payment_gateway_payment_id: NotRequired[str] + downloadable_invoice: NotRequired[str] + downloadable_refund_invoices: NotRequired[List[str]] + refund_disqualification_reasons: NotRequired[List[str]] + flags: int + sku: NotRequired[SKU] + payment_source: NotRequired[PartialPaymentSource] + subscription: NotRequired[PartialSubscription] diff --git a/discord/types/promotions.py b/discord/types/promotions.py new file mode 100644 index 000000000..e49d3877e --- /dev/null +++ b/discord/types/promotions.py @@ -0,0 +1,79 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Dolfies + +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, Optional, TypedDict +from typing_extensions import NotRequired + +from .snowflake import Snowflake +from .subscriptions import SubscriptionTrial + + +class Promotion(TypedDict): + id: Snowflake + trial_id: NotRequired[Snowflake] + start_date: str + end_date: str + flags: int + outbound_title: str + outbound_redemption_modal_body: str + outbound_redemption_page_link: NotRequired[str] + outbound_redemption_url_format: NotRequired[str] + outbound_restricted_countries: NotRequired[List[str]] + outbound_terms_and_conditions: str + inbound_title: NotRequired[str] + inbound_body_text: NotRequired[str] + inbound_help_center_link: NotRequired[str] + inbound_restricted_countries: NotRequired[List[str]] + + +class ClaimedPromotion(TypedDict): + promotion: Promotion + code: str + claimed_at: str + + +class TrialOffer(TypedDict): + id: Snowflake + expires_at: str + trial_id: Snowflake + subscription_trial: SubscriptionTrial + + +class PromotionalPrice(TypedDict): + amount: int + currency: str + + +class PricingPromotion(TypedDict): + plan_id: Snowflake + country_code: str + payment_source_types: List[str] + price: PromotionalPrice + + +class WrappedPricingPromotion(TypedDict): + country_code: str + localized_pricing_promo: Optional[PricingPromotion] diff --git a/discord/types/store.py b/discord/types/store.py new file mode 100644 index 000000000..19be1ca67 --- /dev/null +++ b/discord/types/store.py @@ -0,0 +1,152 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Dolfies + +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 Dict, List, Literal, Optional, TypedDict, Union +from typing_extensions import NotRequired + +from .appinfo import PartialApplication, StoreAsset +from .entitlements import Entitlement +from .guild import PartialGuild +from .library import LibraryApplication +from .snowflake import Snowflake +from .user import PartialUser + +LOCALIZED_STR = Union[str, Dict[str, str]] + + +class StoreNote(TypedDict): + content: str + user: Optional[PartialUser] + + +class SystemRequirement(TypedDict, total=False): + ram: int + disk: int + operating_system_version: LOCALIZED_STR + cpu: LOCALIZED_STR + gpu: LOCALIZED_STR + sound_card: LOCALIZED_STR + directx: LOCALIZED_STR + network: LOCALIZED_STR + notes: LOCALIZED_STR + + +class SystemRequirements(TypedDict, total=False): + minimum: SystemRequirement + recommended: SystemRequirement + + +class CarouselItem(TypedDict, total=False): + asset_id: Snowflake + youtube_video_id: str + + +class StoreListing(TypedDict): + id: Snowflake + summary: NotRequired[LOCALIZED_STR] + description: NotRequired[LOCALIZED_STR] + tagline: NotRequired[LOCALIZED_STR] + flavor_text: NotRequired[str] + published: NotRequired[bool] + entitlement_branch_id: NotRequired[Snowflake] + staff_notes: NotRequired[StoreNote] + guild: NotRequired[PartialGuild] + assets: NotRequired[List[StoreAsset]] + carousel_items: NotRequired[List[CarouselItem]] + preview_video: NotRequired[StoreAsset] + header_background: NotRequired[StoreAsset] + hero_background: NotRequired[StoreAsset] + hero_video: NotRequired[StoreAsset] + box_art: NotRequired[StoreAsset] + thumbnail: NotRequired[StoreAsset] + header_logo_light_theme: NotRequired[StoreAsset] + header_logo_dark_theme: NotRequired[StoreAsset] + sku: SKU + child_skus: NotRequired[List[SKU]] + alternative_skus: NotRequired[List[SKU]] + + +class SKUPrice(TypedDict): + currency: str + amount: int + sale_amount: NotRequired[Optional[int]] + sale_percentage: NotRequired[int] + premium: NotRequired[bool] + + +class ContentRating(TypedDict): + rating: int + descriptors: List[int] + + +class PartialSKU(TypedDict): + id: Snowflake + type: Literal[1, 2, 3, 4, 5, 6] + premium: bool + preorder_release_date: Optional[str] + preorder_released_at: Optional[str] + + +class SKU(PartialSKU): + id: Snowflake + type: Literal[1, 2, 3, 4, 5, 6] + name: LOCALIZED_STR + summary: NotRequired[LOCALIZED_STR] + legal_notice: NotRequired[LOCALIZED_STR] + slug: str + dependent_sku_id: Optional[Snowflake] + application_id: Snowflake + application: NotRequired[PartialApplication] + flags: int + price_tier: NotRequired[int] + price: NotRequired[Union[SKUPrice, Dict[str, int]]] + sale_price_tier: NotRequired[int] + sale_price: NotRequired[Dict[str, int]] + access_level: Literal[1, 2, 3] + features: List[int] + locales: NotRequired[List[str]] + genres: NotRequired[List[int]] + available_regions: NotRequired[List[str]] + content_rating_agency: NotRequired[Literal[1, 2]] + content_rating: NotRequired[ContentRating] + content_ratings: NotRequired[Dict[Literal[1, 2], ContentRating]] + system_requirements: NotRequired[Dict[Literal[1, 2, 3], SystemRequirements]] + release_date: Optional[str] + preorder_release_date: NotRequired[Optional[str]] + preorder_released_at: NotRequired[Optional[str]] + external_purchase_url: NotRequired[str] + premium: NotRequired[bool] + restricted: NotRequired[bool] + exclusive: NotRequired[bool] + show_age_gate: bool + bundled_skus: NotRequired[List[SKU]] + manifest_labels: Optional[List[Snowflake]] + + +class SKUPurchase(TypedDict): + entitlements: List[Entitlement] + library_applications: NotRequired[List[LibraryApplication]] + gift_code: NotRequired[str] diff --git a/discord/types/subscriptions.py b/discord/types/subscriptions.py new file mode 100644 index 000000000..792abb02f --- /dev/null +++ b/discord/types/subscriptions.py @@ -0,0 +1,161 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Dolfies + +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 Any, Dict, List, Literal, Optional, TypedDict +from typing_extensions import NotRequired + +from .snowflake import Snowflake +from .user import PartialUser + + +class PremiumGuildSubscription(TypedDict): + id: Snowflake + guild_id: Snowflake + user_id: Snowflake + user: NotRequired[PartialUser] + ended: bool + ends_at: NotRequired[str] + + +class PremiumGuildSubscriptionSlot(TypedDict): + id: Snowflake + subscription_id: Snowflake + canceled: bool + cooldown_ends_at: Optional[str] + premium_guild_subscription: Optional[PremiumGuildSubscription] + + +class PremiumGuildSubscriptionCooldown(TypedDict): + ends_at: str + limit: int + remaining: int + + +class SubscriptionItem(TypedDict): + id: Snowflake + quantity: int + plan_id: Snowflake + + +class SubscriptionDiscount(TypedDict): + type: Literal[1, 2, 3, 4] + amount: int + + +class SubscriptionInvoiceItem(TypedDict): + id: Snowflake + quantity: int + amount: int + proration: bool + subscription_plan_id: Snowflake + subscription_plan_price: int + discounts: List[SubscriptionDiscount] + + +class SubscriptionInvoice(TypedDict): + id: Snowflake + status: NotRequired[Literal[1, 2, 3, 4]] + currency: str + subtotal: int + tax: int + total: int + tax_inclusive: bool + items: List[SubscriptionInvoiceItem] + current_period_start: str + current_period_end: str + + +class SubscriptionRenewalMutations(TypedDict, total=False): + payment_gateway_plan_id: Optional[str] + items: List[SubscriptionItem] + + +class PartialSubscription(TypedDict): + id: Snowflake + type: Literal[1, 2, 3] + payment_gateway: Optional[Literal[1, 2, 3, 4, 5, 6]] + currency: str + items: List[SubscriptionItem] + payment_gateway_plan_id: Optional[str] + payment_gateway_subscription_id: NotRequired[Optional[str]] + current_period_start: str + current_period_end: str + streak_started_at: NotRequired[str] + + +class Subscription(PartialSubscription): + status: Literal[0, 1, 2, 3, 4, 5, 6] + renewal_mutations: NotRequired[SubscriptionRenewalMutations] + trial_id: NotRequired[Snowflake] + payment_source_id: Optional[Snowflake] + created_at: str + canceled_at: NotRequired[str] + trial_ends_at: NotRequired[str] + metadata: NotRequired[Dict[str, Any]] + latest_invoice: NotRequired[SubscriptionInvoice] + + +class SubscriptionTrial(TypedDict): + id: Snowflake + interval: Literal[1, 2, 3] + interval_count: int + sku_id: Snowflake + + +class SubscriptionPrice(TypedDict): + currency: str + amount: int + exponent: int + + +class SubscriptionCountryPrice(TypedDict): + country_code: str + prices: List[SubscriptionPrice] + + +class SubscriptionPrices(TypedDict): + country_prices: SubscriptionCountryPrice + payment_source_prices: Dict[Snowflake, List[SubscriptionPrice]] + + +class PartialSubscriptionPlan(TypedDict): + id: Snowflake + name: str + sku_id: Snowflake + interval: Literal[1, 2, 3] + interval_count: int + tax_inclusive: bool + + +class SubscriptionPlan(PartialSubscriptionPlan): + prices: Dict[Literal[0, 1, 2, 3, 4], SubscriptionPrices] + price_tier: Literal[None] + currency: str + price: int + discount_price: NotRequired[int] + fallback_currency: NotRequired[str] + fallback_price: NotRequired[int] + fallback_discount_price: NotRequired[int] diff --git a/discord/types/team.py b/discord/types/team.py index d6635ea5f..4bfa644c0 100644 --- a/discord/types/team.py +++ b/discord/types/team.py @@ -24,14 +24,15 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations -from typing import TypedDict, List, Optional +from typing import Literal, TypedDict, List, Optional +from typing_extensions import NotRequired -from .user import User +from .user import PartialUser from .snowflake import Snowflake class TeamMember(TypedDict): - user: User + user: PartialUser membership_state: int permissions: List[str] team_id: Snowflake @@ -41,5 +42,18 @@ class Team(TypedDict): id: Snowflake name: str owner_user_id: Snowflake - members: List[TeamMember] icon: Optional[str] + payout_account_status: NotRequired[Optional[Literal[1, 2, 3, 4, 5, 6]]] + stripe_connect_account_id: NotRequired[Optional[str]] + members: NotRequired[List[TeamMember]] + + +class TeamPayout(TypedDict): + id: Snowflake + user_id: Snowflake + amount: int + status: Literal[1, 2, 3, 4, 5] + period_start: str + period_end: Optional[str] + payout_date: Optional[str] + latest_tipalti_submission_response: NotRequired[dict] diff --git a/discord/types/user.py b/discord/types/user.py index 1b3f4d0e0..87a90cc34 100644 --- a/discord/types/user.py +++ b/discord/types/user.py @@ -34,6 +34,10 @@ class PartialUser(TypedDict): username: str discriminator: str avatar: Optional[str] + avatar_decoration: NotRequired[Optional[str]] + public_flags: NotRequired[int] + bot: NotRequired[bool] + system: NotRequired[bool] ConnectionType = Literal[ @@ -60,12 +64,10 @@ ConnectionType = Literal[ 'xbox', ] ConnectionVisibilty = Literal[0, 1] -PremiumType = Literal[0, 1, 2] +PremiumType = Literal[0, 1, 2, 3] class User(PartialUser, total=False): - bot: bool - system: bool mfa_enabled: bool locale: str verified: bool @@ -74,8 +76,6 @@ class User(PartialUser, total=False): purchased_flags: int premium_usage_flags: int premium_type: PremiumType - public_flags: int - avatar_decoration: Optional[str] banner: Optional[str] accent_color: Optional[int] bio: str @@ -90,6 +90,7 @@ class PartialConnection(TypedDict): type: ConnectionType name: str verified: bool + metadata: NotRequired[Dict[str, Any]] class Connection(PartialConnection): @@ -101,7 +102,6 @@ class Connection(PartialConnection): two_way_link: bool integrations: NotRequired[List[ConnectionIntegration]] access_token: NotRequired[str] - metadata: NotRequired[Dict[str, Any]] class ConnectionAccessToken(TypedDict): diff --git a/discord/user.py b/discord/user.py index 392d07702..cef517253 100644 --- a/discord/user.py +++ b/discord/user.py @@ -312,7 +312,7 @@ class BaseUser(_UserTag): return self - def _to_minimal_user_json(self) -> UserPayload: + def _to_minimal_user_json(self) -> PartialUserPayload: user: UserPayload = { 'username': self.name, 'id': self.id, @@ -534,8 +534,6 @@ class ClientUser(BaseUser): The phone number of the user. .. versionadded:: 1.9 - locale: Optional[:class:`Locale`] - The IETF language tag used to identify the language the user is using. mfa_enabled: :class:`bool` Specifies if the user has MFA turned on and working. premium_type: Optional[:class:`PremiumType`] @@ -548,12 +546,20 @@ class ClientUser(BaseUser): Specifies if the user should be allowed to access NSFW content. If ``None``, then the user's date of birth is not known. + .. versionadded:: 2.0 + desktop: :class:`bool` + Specifies whether the user has used a desktop client. + + .. versionadded:: 2.0 + mobile: :class:`bool` + Specifies whether the user has used a mobile client. + .. versionadded:: 2.0 """ __slots__ = ( '__weakref__', - 'locale', + '_locale', '_flags', 'verified', 'mfa_enabled', @@ -563,6 +569,8 @@ class ClientUser(BaseUser): 'note', 'bio', 'nsfw_allowed', + 'desktop', + 'mobile', '_purchased_flags', '_premium_usage_flags', ) @@ -571,7 +579,7 @@ class ClientUser(BaseUser): verified: bool email: Optional[str] phone: Optional[int] - locale: Locale + _locale: str _flags: int mfa_enabled: bool premium_type: Optional[PremiumType] @@ -594,14 +602,16 @@ class ClientUser(BaseUser): self.verified = data.get('verified', False) self.email = data.get('email') self.phone = _get_as_snowflake(data, 'phone') - self.locale = try_enum(Locale, data.get('locale', 'en-US')) + self._locale = data.get('locale', 'en-US') self._flags = data.get('flags', 0) self._purchased_flags = data.get('purchased_flags', 0) self._premium_usage_flags = data.get('premium_usage_flags', 0) self.mfa_enabled = data.get('mfa_enabled', False) - self.premium_type = try_enum(PremiumType, data['premium_type']) if 'premium_type' in data else None + self.premium_type = try_enum(PremiumType, data.get('premium_type')) if data.get('premium_type') else None self.bio = data.get('bio') or None self.nsfw_allowed = data.get('nsfw_allowed') + self.desktop: bool = data.get('desktop', False) + self.mobile: bool = data.get('mobile', False) def get_relationship(self, user_id: int) -> Optional[Relationship]: """Retrieves the :class:`Relationship` if applicable. @@ -618,6 +628,11 @@ class ClientUser(BaseUser): """ return self._state._relationships.get(user_id) + @property + def locale(self) -> Locale: + """:class:`Locale`: The IETF language tag used to identify the language the user is using.""" + return self.settings.locale if self.settings else try_enum(Locale, self._locale) + @property def premium(self) -> bool: """Indicates if the user is a premium user (i.e. has Discord Nitro).""" @@ -687,7 +702,7 @@ class ClientUser(BaseUser): *, username: str = MISSING, avatar: Optional[bytes] = MISSING, - avatar_decoration: Optional[bool] = MISSING, + avatar_decoration: Optional[bytes] = MISSING, password: str = MISSING, new_password: str = MISSING, email: str = MISSING, @@ -737,7 +752,7 @@ class ClientUser(BaseUser): avatar: Optional[:class:`bytes`] A :term:`py:bytes-like object` representing the image to upload. Could be ``None`` to denote no avatar. - avatar_decoration: Optional[:class:`bool`] + avatar_decoration: Optional[:class:`bytes`] A :term:`py:bytes-like object` representing the image to upload. Could be ``None`` to denote no avatar decoration. @@ -747,12 +762,18 @@ class ClientUser(BaseUser): Could be ``None`` to denote no banner. accent_colour: :class:`Colour` A :class:`Colour` object of the colour you want to set your profile to. + + .. versionadded:: 2.0 bio: :class:`str` Your "about me" section. Could be ``None`` to represent no bio. + + .. versionadded:: 2.0 date_of_birth: :class:`datetime.datetime` Your date of birth. Can only ever be set once. + .. versionadded:: 2.0 + Raises ------ HTTPException @@ -781,6 +802,12 @@ class ClientUser(BaseUser): else: args['avatar'] = None + if avatar_decoration is not MISSING: + if avatar_decoration is not None: + args['avatar_decoration'] = _bytes_to_base64_data(avatar_decoration) + else: + args['avatar_decoration'] = None + if banner is not MISSING: if banner is not None: args['banner'] = _bytes_to_base64_data(banner) diff --git a/discord/utils.py b/discord/utils.py index 26da71cfb..94a5883df 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -79,6 +79,8 @@ except ModuleNotFoundError: else: HAS_ORJSON = True +from .enums import Locale, try_enum + __all__ = ( 'oauth_url', @@ -145,6 +147,7 @@ if TYPE_CHECKING: from .message import Message from .template import Template from .commands import ApplicationCommand + from .entitlements import Gift class _RequestLike(Protocol): headers: Mapping[str, Any] @@ -260,6 +263,27 @@ def parse_time(timestamp: Optional[str]) -> Optional[datetime.datetime]: return None +@overload +def parse_date(date: None) -> None: + ... + + +@overload +def parse_date(date: str) -> datetime.date: + ... + + +@overload +def parse_date(date: Optional[str]) -> Optional[datetime.date]: + ... + + +def parse_date(date: Optional[str]) -> Optional[datetime.date]: + if date: + return parse_time(date).date() + return None + + def copy_doc(original: Callable) -> Callable[[T], T]: def decorator(overridden: T) -> T: overridden.__doc__ = original.__doc__ @@ -574,7 +598,7 @@ def _get_as_snowflake(data: Any, key: str) -> Optional[int]: return value and int(value) -def _get_mime_type_for_image(data: bytes): +def _get_mime_type_for_image(data: bytes, with_video: bool = False, fallback: bool = False) -> str: if data.startswith(b'\x89\x50\x4E\x47\x0D\x0A\x1A\x0A'): return 'image/png' elif data[0:3] == b'\xff\xd8\xff' or data[6:10] in (b'JFIF', b'Exif'): @@ -583,13 +607,30 @@ def _get_mime_type_for_image(data: bytes): return 'image/gif' elif data.startswith(b'RIFF') and data[8:12] == b'WEBP': return 'image/webp' + elif data.startswith(b'\x66\x74\x79\x70\x69\x73\x6F\x6D') and with_video: + return 'video/mp4' else: + if fallback: + return 'application/octet-stream' raise ValueError('Unsupported image type given') +def _get_extension_for_mime_type(mime_type: str) -> str: + if mime_type == 'image/png': + return 'png' + elif mime_type == 'image/jpeg': + return 'jpg' + elif mime_type == 'image/gif': + return 'gif' + elif mime_type == 'video/mp4': + return 'mp4' + else: + return 'webp' + + def _bytes_to_base64_data(data: bytes) -> str: fmt = 'data:{mime};base64,{data}' - mime = _get_mime_type_for_image(data) + mime = _get_mime_type_for_image(data, fallback=True) b64 = b64encode(data).decode('ascii') return fmt.format(mime=mime, data=b64) @@ -598,17 +639,24 @@ def _is_submodule(parent: str, child: str) -> bool: return parent == child or child.startswith(parent + '.') +def _handle_metadata(obj): + try: + return dict(obj) + except Exception: + raise TypeError(f'Type {obj.__class__.__name__} is not JSON serializable') + + if HAS_ORJSON: def _to_json(obj: Any) -> str: - return orjson.dumps(obj).decode('utf-8') + return orjson.dumps(obj, default=_handle_metadata).decode('utf-8') _from_json = orjson.loads # type: ignore else: def _to_json(obj: Any) -> str: - return json.dumps(obj, separators=(',', ':'), ensure_ascii=True) + return json.dumps(obj, separators=(',', ':'), ensure_ascii=True, default=_handle_metadata) _from_json = json.loads @@ -824,6 +872,34 @@ def resolve_template(code: Union[Template, str]) -> str: return code +def resolve_gift(code: Union[Gift, str]) -> str: + """ + Resolves a gift code from a :class:`~discord.Gift`, URL or code. + + .. versionadded:: 2.0 + + Parameters + ----------- + code: Union[:class:`~discord.Gift`, :class:`str`] + The code. + + Returns + -------- + :class:`str` + The gift code. + """ + from .entitlements import Gift # circular import + + if isinstance(code, Gift): + return code.code + else: + rx = r'(?:https?\:\/\/)?(?:discord(?:app)?\.com\/(?:gifts|billing\/promotions)|promos\.discord\.gg|discord.gift)\/(.+)' + m = re.match(rx, code) + if m: + return m.group(1) + return code + + _MARKDOWN_ESCAPE_SUBREGEX = '|'.join(r'\{0}(?=([\s\S]*((?(?:>>)?\s|\[.+\]\(.+\)' @@ -1195,11 +1271,10 @@ def set_target( for item in items: for k, v in attrs.items(): - if v is not None: - try: - setattr(item, k, v) - except AttributeError: - pass + try: + setattr(item, k, v) + except AttributeError: + pass def _generate_session_id() -> str: @@ -1210,6 +1285,16 @@ def _generate_nonce() -> str: return str(time_snowflake(utcnow())) +def _parse_localizations(data: Any, key: str) -> tuple[Any, dict]: + values = data.get(key) + values = values if isinstance(values, dict) else {'default': values} + string = values['default'] + localizations = { + try_enum(Locale, k): v for k, v in (values.get('localizations', data.get(f'{key}_localizations')) or {}).items() + } + return string, localizations + + class ExpiringString(collections.UserString): def __init__(self, data: str, timeout: int) -> None: super().__init__(data) @@ -1271,7 +1356,9 @@ async def _get_user_agent(session: ClientSession) -> str: return response[0] except asyncio.TimeoutError: _log.critical('Could not fetch user-agent. Falling back to hardcoded value...') - return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36' + return ( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36' + ) def _get_browser_version(user_agent: str) -> str: diff --git a/docs/api.rst b/docs/api.rst index f207ba3ac..f4003413f 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -307,7 +307,7 @@ Debug :param event_type: The event type from Discord that is received, e.g. ``'READY'``. :type event_type: :class:`str` - + .. function:: on_socket_raw_receive(msg) Called whenever a message is completely received from the WebSocket, before @@ -411,9 +411,160 @@ Client :param action: The action required. :type action: :class:`RequiredActionType` +Billing +~~~~~~~ + +.. function:: on_payment_sources_update() + + Called when your payment sources are updated. + + .. versionadded:: 2.0 + +.. function:: on_subscriptions_update() + + Called when your subscriptions are updated. + + .. versionadded:: 2.0 + +.. function:: on_payment_client_add(purchase_token_hash, expires_at) + + Called when a payment client is added to your account. + + .. versionadded:: 2.0 + + :param purchase_token_hash: The purchase token hash. + :type purchase_token_hash: :class:`str` + :param expires_at: When the payment client expires. + :type expires_at: :class:`datetime.datetime` + +.. function:: on_payment_update(payment) + + Called when a payment is created or updated. + + .. versionadded:: 2.0 + + :param payment: The payment that was updated. + :type payment: :class:`Payment` + +.. function:: on_premium_guild_subscription_slot_create(slot) + + Called when a premium guild subscription (boost) slot is added to your account. + + .. versionadded:: 2.0 + + :param slot: The slot that was added. + :type slot: :class:`PremiumGuildSubscriptionSlot` + +.. function:: on_premium_guild_subscription_slot_update(slot) + + Called when a premium guild subscription (boost) slot is updated. + + .. versionadded:: 2.0 + + :param slot: The slot that was updated. + :type slot: :class:`PremiumGuildSubscriptionSlot` + +.. function:: on_billing_popup_bridge_callback(payment_source_type, path, query, state) + + Called when a billing popup bridge callback is received. + + .. versionadded:: 2.0 + + :param payment_source_type: The payment source type. + :type payment_source_type: :class:`PaymentSourceType` + :param path: The path of the callback. + :type path: :class:`str` + :param query: The query of the callback. + :type query: :class:`str` + :param state: The state of the callback. + :type state: :class:`str` + +Entitlements +~~~~~~~~~~~~ + +.. function:: on_library_application_update(application) + + Called when a library entry is updated. + + .. versionadded:: 2.0 + + :param application: The library entry that was updated. + :type application: :class:`LibraryApplication` + +.. function:: on_achievement_update(achievement, percent_complete) + + Called when an achievement is updated. + + .. versionadded:: 2.0 + + :param achievement: The achievement that was updated. + :type achievement: :class:`Achievement` + :param percent_complete: The percentage of the acheivement completed. + :type percent_complete: :class:`int` + +.. function:: on_entitlement_create(entitlement) + + Called when an entitlement is added to your account. + + .. versionadded:: 2.0 + + :param entitlement: The entitlement that was added. + :type entitlement: :class:`Entitlement` + +.. function:: on_entitlement_update(entitlement) + + Called when an entitlement is updated. + + .. versionadded:: 2.0 + + :param entitlement: The entitlement that was updated. + :type entitlement: :class:`Entitlement` + +.. function:: on_entitlement_delete(entitlement) + + Called when an entitlement is removed from your account. + + .. versionadded:: 2.0 + + :param entitlement: The entitlement that was removed. + :type entitlement: :class:`Entitlement` + +.. function:: on_gift_create(gift) + + Called when a gift is created. + + .. versionadded:: 2.0 + + .. note:: + + This event does not guarantee most gift attributes. + + :param gift: The gift that was created. + :type gift: :class:`Gift` + +.. function:: on_gift_update(gift) + + Called when a gift is updated. + + .. versionadded:: 2.0 + + .. note:: + + This event does not guarantee most gift attributes. + + :param gift: The gift that was updated. + :type gift: :class:`Gift` + Connections ~~~~~~~~~~~~ +.. function:: on_connections_update() + + Called when your account's connections are updated. + This may not be accompanied by an :meth:`on_connection_create` or :meth:`on_connection_update` event. + + .. versionadded:: 2.0 + .. function:: on_connection_create(connection) Called when a connection is added to your account. @@ -437,6 +588,19 @@ Connections :param after: The connection after being updated. :type after: :class:`Connection` +.. function:: on_connections_link_callback(provider, code, state) + + Called when a connection link callback is received. + + .. versionadded:: 2.0 + + :param provider: The provider that the callback is for. + :type provider: :class:`str` + :param code: The callback code that was received. + :type code: :class:`str` + :param state: The callback state. + :type state: :class:`str` + Relationships ~~~~~~~~~~~~~ @@ -1227,18 +1391,23 @@ of :class:`enum.Enum`. .. attribute:: text A text channel. + .. attribute:: voice A voice channel. + .. attribute:: private A private text channel. Also called a direct message. + .. attribute:: group A private group text channel. + .. attribute:: category A category channel. + .. attribute:: news A guild news channel. @@ -1290,27 +1459,34 @@ of :class:`enum.Enum`. .. attribute:: default The default message type. This is the same as regular messages. + .. attribute:: recipient_add The system message when a user is added to a group private message or a thread. + .. attribute:: recipient_remove The system message when a user is removed from a group private message or a thread. + .. attribute:: call The system message denoting call state, e.g. missed call, started call, etc. + .. attribute:: channel_name_change The system message denoting that a channel's name has been changed. + .. attribute:: channel_icon_change The system message denoting that a channel's icon has been changed. + .. attribute:: pins_add The system message denoting that a pinned message has been added to a channel. + .. attribute:: new_member The system message denoting that a new member has joined a Guild. @@ -1318,18 +1494,22 @@ of :class:`enum.Enum`. .. attribute:: premium_guild_subscription The system message denoting that a member has "nitro boosted" a guild. + .. attribute:: premium_guild_tier_1 The system message denoting that a member has "nitro boosted" a guild and it achieved level 1. + .. attribute:: premium_guild_tier_2 The system message denoting that a member has "nitro boosted" a guild and it achieved level 2. + .. attribute:: premium_guild_tier_3 The system message denoting that a member has "nitro boosted" a guild and it achieved level 3. + .. attribute:: channel_follow_add The system message denoting that an announcement channel has been followed. @@ -1406,9 +1586,11 @@ of :class:`enum.Enum`. .. attribute:: guild A guild invite. + .. attribute:: group_dm A group DM invite. + .. attribute:: friend A friend invite. @@ -1420,15 +1602,19 @@ of :class:`enum.Enum`. .. attribute:: staff The user is a Discord Employee. + .. attribute:: partner The user is a Discord Partner. + .. attribute:: hypesquad The user is a HypeSquad Events member. + .. attribute:: bug_hunter The user is a Bug Hunter. + .. attribute:: bug_hunter_level_1 The user is a Bug Hunter. @@ -1437,27 +1623,35 @@ of :class:`enum.Enum`. .. attribute:: mfa_sms The user has SMS recovery for Multi Factor Authentication enabled. + .. attribute:: premium_promo_dismissed The user has dismissed the Discord Nitro promotion. + .. attribute:: hypesquad_bravery The user is a HypeSquad Bravery member. + .. attribute:: hypesquad_brilliance The user is a HypeSquad Brilliance member. + .. attribute:: hypesquad_balance The user is a HypeSquad Balance member. + .. attribute:: early_supporter The user is an Early Supporter. + .. attribute:: team_user The user is a Team User. + .. attribute:: partner_or_verification_application The user has a partner or verification application. + .. attribute:: system The user is a system user (i.e. represents Discord officially). @@ -1466,9 +1660,11 @@ of :class:`enum.Enum`. .. attribute:: has_unread_urgent_messages The user has an unread system message. + .. attribute:: bug_hunter_level_2 The user is a Bug Hunter Level 2. + .. attribute:: underage_deleted The user has been flagged for deletion for being underage. @@ -1477,12 +1673,15 @@ of :class:`enum.Enum`. .. attribute:: verified_bot The user is a Verified Bot. + .. attribute:: verified_bot_developer The user is an Early Verified Bot Developer. + .. attribute:: discord_certified_moderator The user is a Discord Certified Moderator. + .. attribute:: bot_http_interactions The user is a bot that only uses HTTP interactions and is shown in the online member list. @@ -1497,6 +1696,11 @@ of :class:`enum.Enum`. The user bought premium but has it manually disabled. + .. versionadded:: 2.0 + .. attribute:: quarantined + + The user is quarantined. + .. versionadded:: 2.0 .. class:: ActivityType @@ -1507,21 +1711,27 @@ of :class:`enum.Enum`. .. attribute:: unknown An unknown activity type. This should generally not happen. + .. attribute:: playing A "Playing" activity type. + .. attribute:: streaming A "Streaming" activity type. + .. attribute:: listening A "Listening" activity type. + .. attribute:: watching A "Watching" activity type. + .. attribute:: custom A custom activity type. + .. attribute:: competing A competing activity type. @@ -1535,9 +1745,11 @@ of :class:`enum.Enum`. .. attribute:: bravery The "Bravery" house. + .. attribute:: brilliance The "Brilliance" house. + .. attribute:: balance The "Balance" house. @@ -1573,18 +1785,22 @@ of :class:`enum.Enum`. .. attribute:: none No criteria set. + .. attribute:: low Member must have a verified email on their Discord account. + .. attribute:: medium Member must have a verified email and be registered on Discord for more than five minutes. + .. attribute:: high Member must have a verified email, be registered on Discord for more than five minutes, and be a member of the guild itself for more than ten minutes. + .. attribute:: highest Member must have a verified phone on their Discord account. @@ -1619,11 +1835,11 @@ of :class:`enum.Enum`. .. attribute:: all_messages Members receive notifications for every message regardless of them being mentioned. + .. attribute:: only_mentions Members receive notifications for messages they are mentioned in. - .. class:: HighlightLevel Specifies whether a :class:`Guild` has highlights included in notifications. @@ -1634,9 +1850,11 @@ of :class:`enum.Enum`. The highlight level is set to Discord default. This seems to always be enabled, which makes the purpose of this enum unclear. + .. attribute:: disabled Members do not receive additional notifications for highlights. + .. attribute:: enabled Members receive additional notifications for highlights. @@ -1673,9 +1891,11 @@ of :class:`enum.Enum`. .. attribute:: disabled The guild does not have the content filter enabled. + .. attribute:: no_role The guild has the content filter enabled for members without a role. + .. attribute:: all_members The guild has the content filter enabled for every member. @@ -1687,25 +1907,29 @@ of :class:`enum.Enum`. .. attribute:: online The member is online. + .. attribute:: offline The member is offline. + .. attribute:: idle The member is idle. + .. attribute:: dnd The member is "Do Not Disturb". + .. attribute:: do_not_disturb An alias for :attr:`dnd`. + .. attribute:: invisible The member is "invisible". In reality, this is only used in sending a presence a la :meth:`Client.change_presence`. When you receive a user's presence this will be :attr:`offline` instead. - .. class:: AuditLogAction Represents the type of action being done for a :class:`AuditLogEntry`\, @@ -2441,12 +2665,38 @@ of :class:`enum.Enum`. The action is the update of something. -.. class:: TeamMembershipState +.. class:: ApplicationType + + Represents the type of an :class:`Application`. + + .. versionadded:: 2.0 + + .. attribute:: game + + The application is a game. + + .. attribute:: music + + The application is music-related. + + .. attribute:: ticketed_events + + The application can use ticketed event. + + .. attribute:: guild_role_subscriptions + + The application can make custom guild role subscriptions. - Represents the membership state of a :class:`TeamMember`. +.. class:: ApplicationMembershipState + + Represents the membership state of a :class:`TeamMember` or :class:`ApplicationTester`. .. versionadded:: 1.3 + .. versionchanged:: 2.0 + + Renamed from ``TeamMembershipState``. + .. container:: operations .. versionadded:: 2.0 @@ -2472,37 +2722,15 @@ of :class:`enum.Enum`. .. attribute:: invited - Represents an invited member. + Represents an invited user. .. attribute:: accepted - Represents a member currently in the team. - -.. class:: ApplicationType - - Represents the type of an application. - - .. versionadded:: 2.0 - - .. attribute:: none - - The application does not have a special type. - - .. attribute:: game - - The application is a game. - - .. attribute:: music - - The application is music-related. - - .. attribute:: ticketed_events - - The application can use ticketed event. + Represents a user that has accepted the given invite. .. class:: ApplicationVerificationState - Represents the verification application state of a :class:`Application`. + Represents the verification application state of an :class:`Application`. .. versionadded:: 2.0 @@ -2536,7 +2764,7 @@ of :class:`enum.Enum`. The application is has not submitted a verification request. .. attribute:: submitted - + The application has submitted a verification request and is pending a response. .. attribute:: succeeded @@ -2545,7 +2773,7 @@ of :class:`enum.Enum`. .. class:: StoreApplicationState - Represents the commerce application state of a :class:`Application`. + Represents the commerce application state of an :class:`Application`. .. versionadded:: 2.0 @@ -2579,7 +2807,7 @@ of :class:`enum.Enum`. The application has paid the commerce feature fee. .. attribute:: submitted - + The application has submitted a commerce application and is pending a response. .. attribute:: approved @@ -2596,7 +2824,7 @@ of :class:`enum.Enum`. .. class:: RPCApplicationState - Represents the RPC application state of a :class:`Application`. + Represents the RPC application state of an :class:`Application`. .. versionadded:: 2.0 @@ -2634,7 +2862,7 @@ of :class:`enum.Enum`. The application has not submitted a RPC application. .. attribute:: submitted - + The application has submitted a RPC application and is pending a response. .. attribute:: approved @@ -2649,290 +2877,1526 @@ of :class:`enum.Enum`. The application has been blocked from using commerce features. -.. class:: WebhookType +.. class:: ApplicationDiscoverabilityState - Represents the type of webhook that can be received. + Represents the discoverability state of an :class:`Application`. - .. versionadded:: 1.3 + .. versionadded:: 2.0 - .. attribute:: incoming + .. container:: operations - Represents a webhook that can post messages to channels with a token. + .. describe:: x == y - .. attribute:: channel_follower + Checks if two application states are equal. + .. describe:: x != y - Represents a webhook that is internally managed by Discord, used for following channels. + Checks if two application states are not equal. + .. describe:: x > y - .. attribute:: application + Checks if a application state is higher than another. + .. describe:: x < y - Represents a webhook that is used for interactions or applications. + Checks if a application state is lower than another. + .. describe:: x >= y - .. versionadded:: 2.0 + Checks if a application state is higher or equal to another. + .. describe:: x <= y -.. class:: ExpireBehaviour + Checks if a application state is lower or equal to another. - Represents the behaviour the :class:`Integration` should perform - when a user's subscription has finished. + .. attribute:: ineligible - There is an alias for this called ``ExpireBehavior``. + The application is ineligible for appearing on app discovery. - .. versionadded:: 1.4 + .. attribute:: not_discoverable - .. attribute:: remove_role + The application is not discoverable on app discovery. - This will remove the :attr:`StreamIntegration.role` from the user - when their subscription is finished. + .. attribute:: discoverable - .. attribute:: kick + The application is discoverable on app discovery. - This will kick the user when their subscription is finished. + .. attribute:: featureable -.. class:: DefaultAvatar + The application is featureable on app discovery. - Represents the default avatar of a Discord :class:`User` + .. attribute:: blocked - .. attribute:: blurple + The application is blocked from appearing on app discovery. - Represents the default avatar with the color blurple. - See also :attr:`Colour.blurple` - .. attribute:: grey +.. class:: ApplicationBuildStatus - Represents the default avatar with the color grey. - See also :attr:`Colour.greyple` - .. attribute:: gray + Represents the status of an :class:`ApplicationBuild`. - An alias for :attr:`grey`. - .. attribute:: green + .. versionadded:: 2.0 - Represents the default avatar with the color green. - See also :attr:`Colour.green` - .. attribute:: orange + .. attribute:: created - Represents the default avatar with the color orange. - See also :attr:`Colour.orange` - .. attribute:: red + The build has been created. - Represents the default avatar with the color red. - See also :attr:`Colour.red` + .. attribute:: uploading - .. attribute:: pink + The build is being uploaded. - Represents the default avatar with the color pink. - This is not currently used in the client. + .. attribute:: uploaded -.. class:: StickerType + The build has been uploaded. - Represents the type of sticker. + .. attribute:: validating - .. versionadded:: 2.0 + The build is being validated. - .. attribute:: standard + .. attribute:: invalid + + The build is invalid. + + .. attribute:: corrupted + + The build is corrupted. + + .. attribute:: ready + + The build is ready to be published. + +.. class:: EmbeddedActivityPlatform + + Represents an available platform for a :class:`EmbeddedActivityConfig`. + + .. versionadded:: 2.0 + + .. attribute:: web + + The activity is available on web/desktop. + + .. attribute:: ios + + The activity is available on iOS. + + .. attribute:: android + + The activity is available on Android. + +.. class:: EmbeddedActivityOrientation + + Represents an orientation capability of a :class:`EmbeddedActivityConfig`. + + This is only used by mobile clients. + + .. versionadded:: 2.0 + + .. attribute:: unlocked + + The activity can be rotated. + + .. attribute:: portrait + + The activity is locked to portrait. + + .. attribute:: landscape + + The activity is locked to landscape. + +.. class:: PayoutAccountStatus + + Represents the status of a team payout account. + + .. versionadded:: 2.0 + + .. attribute:: unsubmitted + + The payout account application has not been submitted. + + .. attribute:: pending + + The payout account is pending. + + .. attribute:: action_required + + The payout account requires action. + + .. attribute:: active + + The payout account is active. + + .. attribute:: blocked + + The payout account is blocked. + + .. attribute:: suspended + + The payout account is suspended. + +.. class:: PayoutStatus + + Represents the status of a team payout. + + .. versionadded:: 2.0 + + .. attribute:: open + + The payout is open. + + .. attribute:: paid + + The payout has been paid out. + + .. attribute:: pending + + The payout is pending. + + .. attribute:: manual + + The payout has been manually made. + + .. attribute:: cancelled + + The payout has been cancelled. + + .. attribute:: canceled + + Alias for :attr:`cancelled`. + + .. attribute:: deferred + + The payout has been deferred. + + .. attribute:: deferred_internal + + The payout has been deferred internally. + + .. attribute:: processing + + The payout is processing. + + .. attribute:: error + + The payout has an error. + + .. attribute:: rejected + + The payout has been rejected. + + .. attribute:: risk_review + + The payout is under risk review. + + .. attribute:: submitted + + The payout has been submitted. + + .. attribute:: pending_funds + + The payout is pending fund transfer. + +.. class:: PayoutReportType + + Represents the type of downloadable payout report. + + .. versionadded:: 2.0 + + .. attribute:: by_sku + + The payout report is by SKU. + + .. attribute:: by_transaction + + The payout report is by transaction. + +.. class:: WebhookType + + Represents the type of webhook that can be received. + + .. versionadded:: 1.3 + + .. attribute:: incoming + + Represents a webhook that can post messages to channels with a token. + + .. attribute:: channel_follower + + Represents a webhook that is internally managed by Discord, used for following channels. + + .. attribute:: application + + Represents a webhook that is used for interactions or applications. + + .. versionadded:: 2.0 + +.. class:: ExpireBehaviour + + Represents the behaviour the :class:`Integration` should perform + when a user's subscription has finished. + + There is an alias for this called ``ExpireBehavior``. + + .. versionadded:: 1.4 + + .. attribute:: remove_role + + This will remove the :attr:`StreamIntegration.role` from the user + when their subscription is finished. + + .. attribute:: kick + + This will kick the user when their subscription is finished. + +.. class:: DefaultAvatar + + Represents the default avatar of a Discord :class:`User` + + .. attribute:: blurple + + Represents the default avatar with the color blurple. + See also :attr:`Colour.blurple` + + .. attribute:: grey + + Represents the default avatar with the color grey. + See also :attr:`Colour.greyple` + + .. attribute:: gray + + An alias for :attr:`grey`. + + .. attribute:: green + + Represents the default avatar with the color green. + See also :attr:`Colour.green` + + .. attribute:: orange + + Represents the default avatar with the color orange. + See also :attr:`Colour.orange` + + .. attribute:: red + + Represents the default avatar with the color red. + See also :attr:`Colour.red` + + .. attribute:: pink + + Represents the default avatar with the color pink. + This is not currently used in the client. + +.. class:: StickerType + + Represents the type of sticker. + + .. versionadded:: 2.0 + + .. attribute:: standard Represents a standard sticker that all Nitro users can use. - .. attribute:: guild + .. attribute:: guild + + Represents a custom sticker created in a guild. + +.. class:: StickerFormatType + + Represents the type of sticker images. + + .. versionadded:: 1.6 + + .. attribute:: png + + Represents a sticker with a png image. + + .. attribute:: apng + + Represents a sticker with an apng image. + + .. attribute:: lottie + + Represents a sticker with a lottie image. + +.. class:: InviteTarget + + Represents the invite type for voice channel invites. + + .. versionadded:: 2.0 + + .. attribute:: unknown + + The invite doesn't target anyone or anything. + + .. attribute:: stream + + A stream invite that targets a user. + + .. attribute:: embedded_application + + A stream invite that targets an embedded application. + +.. class:: VideoQualityMode + + Represents the camera video quality mode for voice channel participants. + + .. versionadded:: 2.0 + + .. attribute:: auto + + Represents auto camera video quality. + + .. attribute:: full + + Represents full camera video quality. + +.. class:: PrivacyLevel + + Represents the privacy level of a stage instance or scheduled event. + + .. versionadded:: 2.0 + + .. attribute:: guild_only + + The stage instance or scheduled event is only accessible within the guild. + +.. class:: NSFWLevel + + Represents the NSFW level of a guild. + + .. versionadded:: 2.0 + + .. container:: operations + + .. describe:: x == y + + Checks if two NSFW levels are equal. + .. describe:: x != y + + Checks if two NSFW levels are not equal. + .. describe:: x > y + + Checks if a NSFW level is higher than another. + .. describe:: x < y + + Checks if a NSFW level is lower than another. + .. describe:: x >= y + + Checks if a NSFW level is higher or equal to another. + .. describe:: x <= y + + Checks if a NSFW level is lower or equal to another. + + .. attribute:: default + + The guild has not been categorised yet. + + .. attribute:: explicit + + The guild contains NSFW content. + + .. attribute:: safe + + The guild does not contain any NSFW content. + + .. attribute:: age_restricted + + The guild may contain NSFW content. + +.. class:: RelationshipType + + Specifies the type of :class:`Relationship`. + + .. attribute:: friend + + You are friends with this user. + + .. attribute:: blocked + + You have blocked this user. + + .. attribute:: incoming_request + + The user has sent you a friend request. + + .. attribute:: outgoing_request + + You have sent a friend request to this user. + +.. class:: UserContentFilter + + Represents the options found in ``Settings > Privacy & Safety > Safe Direct Messaging`` + in the Discord client. + + .. attribute:: all_messages + + Scan all direct messages from everyone. + + .. attribute:: non_friends + + Scan all direct messages that aren't from friends. + + .. attribute:: disabled + + Don't scan any direct messages. + +.. class:: FriendFlags + + Represents the options found in ``Settings > Privacy & Safety > Who Can Add You As A Friend`` + in the Discord client. + + .. attribute:: noone + + This allows no-one to add you as a friend. + + .. attribute:: mutual_guilds + + This allows guild members to add you as a friend. + + .. attribute:: mutual_friends + + This allows friends of friends to add you as a friend. + + .. attribute:: guild_and_friends + + This is a superset of :attr:`mutual_guilds` and :attr:`mutual_friends`. + + .. attribute:: everyone + + This allows everyone to add you as a friend. + +.. class:: PremiumType + + Represents the user's Discord Nitro subscription type. + + .. attribute:: nitro + + Represents the new, full Discord Nitro. + + .. attribute:: nitro_classic + + Represents the classic Discord Nitro. + + .. attribute:: nitro_basic + + Represents the basic Discord Nitro. + + .. versionadded:: 2.0 + +.. class:: PaymentSourceType + + Represents the type of a payment source. + + .. versionadded:: 2.0 + + .. attribute:: unknown + + The payment source is unknown. + + .. attribute:: credit_card + + The payment source is a credit card. + + .. attribute:: paypal + + The payment source is a PayPal account. + + .. attribute:: giropay + + The payment source is a Giropay account. + + .. attribute:: sofort + + The payment source is a Sofort account. + + .. attribute:: przelewy24 + + The payment source is a Przelewy24 account. + + .. attribute:: sepa_debit + + The payment source is a SEPA debit account. + + .. attribute:: paysafecard + + The payment source is a Paysafe card. + + .. attribute:: gcash + + The payment source is a GCash account. + + .. attribute:: grabpay + + The payment source is a GrabPay (Malaysia) account. + + .. attribute:: momo_wallet + + The payment source is a MoMo Wallet account. + + .. attribute:: venmo + + The payment source is a Venmo account. + + .. attribute:: gopay_wallet + + The payment source is a GoPay Wallet account. + + .. attribute:: kakaopay + + The payment source is a KakaoPay account. + + .. attribute:: bancontact + + The payment source is a Bancontact account. + + .. attribute:: eps + + The payment source is an EPS account. + + .. attribute:: ideal + + The payment source is an iDEAL account. + +.. class:: PaymentGateway + + Represents the payment gateway used for a payment source. + + .. versionadded:: 2.0 + + .. attribute:: stripe + + The payment source is a Stripe payment source. + + .. attribute:: braintree + + The payment source is a Braintree payment source. + + .. attribute:: apple + + The payment source is an Apple payment source. + + .. attribute:: google + + The payment source is a Google payment source. + + .. attribute:: adyen + + The payment source is an Adyen payment source. + + .. attribute:: apple_pay + + The payment source is an Apple Pay payment source (unconfirmed). + +.. class:: SubscriptionType + + Represents the type of a subscription. + + .. versionadded:: 2.0 + + .. attribute:: premium + + The subscription is a Discord premium (Nitro) subscription. + + .. attribute:: guild + + The subscription is a guild role subscription. + + .. attribute:: application + + The subscription is an application subscription. + +.. class:: SubscriptionStatus + + Represents the status of a subscription. + + .. versionadded:: 2.0 + + .. attribute:: unpaid + + The subscription is unpaid. + + .. attribute:: active + + The subscription is active. + + .. attribute:: past_due + + The subscription is past due. + + .. attribute:: canceled + + The subscription is canceled. + + .. attribute:: ended + + The subscription has ended. + + .. attribute:: inactive + + The subscription is inactive. + + .. attribute:: account_hold + + The subscription is on account hold. + +.. class:: SubscriptionInvoiceStatus + + Represents the status of a subscription invoice. + + .. versionadded:: 2.0 + + .. container:: operations + + .. describe:: x == y + + Checks if two subscription invoice statuses are equal. + .. describe:: x != y + + Checks if two subscription invoice statuses are not equal. + .. describe:: x > y + + Checks if a subscription invoice status is higher than another. + .. describe:: x < y + + Checks if a subscription invoice status is lower than another. + .. describe:: x >= y + + Checks if a subscription invoice status is higher or equal to another. + .. describe:: x <= y + + Checks if a subscription invoice status is lower or equal to another. + + .. attribute:: open + + The invoice is open. + + .. attribute:: paid + + The invoice is paid. + + .. attribute:: void + + The invoice is void. + + .. attribute:: uncollectible + + The invoice is uncollectible. + +.. class:: SubscriptionDiscountType + + Represents the type of a subscription discount. + + .. versionadded:: 2.0 + + .. attribute:: subscription_plan + + The discount is from an existing subscription plan's remaining credit. + + .. attribute:: entitlement + + The discount is from an applied entitlement. + + .. attribute:: premium_legacy_upgrade_promotion + + The discount is from a legacy premium plan promotion discount. + + .. attribute:: premium_trial + + The discount is from a premium trial. + +.. class:: SubscriptionInterval + + Represents the interval of a subscription. + + .. versionadded:: 2.0 + + .. attribute:: month + + The subscription is billed monthly. + + .. attribute:: year + + The subscription is billed yearly. + + .. attribute:: day + + The subscription is billed daily. + +.. class:: SubscriptionPlanPurchaseType + + Represents the different types of subscription plan purchases. + + .. versionadded:: 2.0 + + .. attribute:: default + + The plan is purchased with default pricing. + + .. attribute:: gift + + The plan is purchased with gift pricing. + + .. attribute:: sale + + The plan is purchased with sale pricing. + + .. attribute:: nitro_classic + + The plan is purchased with Nitro Classic discounted pricing. + + .. attribute:: nitro + + The plan is purchased with Nitro discounted pricing. + +.. class:: PaymentStatus + + Represents the status of a payment. + + .. versionadded:: 2.0 + + .. attribute:: pending + + The payment is pending. + + .. attribute:: completed + + The payment has gone through. + + .. attribute:: failed + + The payment has failed. + + .. attribute:: reversed + + The payment has been reversed. + + .. attribute:: refunded + + The payment has been refunded. + + .. attribute:: cancelled + + The payment has been canceled. + + .. attribute:: canceled + + Alias for :attr:`PaymentStatus.cancelled`. + +.. class:: EntitlementType + + Represents the type of an entitlement. + + .. versionadded:: 2.0 + + .. attribute:: purchase + + The entitlement is from a purchase. + + .. attribute:: premium_subscription + + The entitlement is a Discord premium subscription. + + .. attribute:: developer_gift + + The entitlement is gifted by the developer. + + .. attribute:: test_mode_purchase + + The entitlement is from a free test mode purchase. + + .. attribute:: free_purchase + + The entitlement is a free purchase. + + .. attribute:: user_gift + + The entitlement is gifted by a user. + + .. attribute:: premium_purchase + + The entitlement is a premium subscription perk. + + .. attribute:: application_subscription + + The entitlement is an application subscription. + +.. class:: SKUType + + Represents the type of a SKU. + + .. versionadded:: 2.0 + + .. attribute:: durable_primary + + Represents a primary SKU (game). + + .. attribute:: durable + + Represents a DLC. + + .. attribute:: consumable + + Represents a IAP (in-app purchase). + + .. attribute:: bundle + + Represents a bundle comprising the above. + + .. attribute:: subscription + + Represents a subscription-only SKU. + + .. attribute:: group + + Represents a group of SKUs. + +.. class:: SKUAccessLevel + + Represents the access level of a SKU. + + .. versionadded:: 2.0 + + .. attribute:: full + + The SKU is available to all users. + + .. attribute:: early_access + + The SKU is available in early access only. + + .. attribute:: vip_access + + The SKU is available to VIP users only. + +.. class:: SKUFeature + + Represents a feature of a SKU. + + .. versionadded:: 2.0 + + .. attribute:: single_player + + The SKU supports single player. + + .. attribute:: online_multiplayer + + The SKU supports online multiplayer. + + .. attribute:: local_multiplayer + + The SKU supports local multiplayer. + + .. attribute:: pvp + + The SKU supports PvP. + + .. attribute:: local_coop + + The SKU supports local co-op. + + .. attribute:: cross_platform + + The SKU supports cross-platform play. + + .. attribute:: rich_presence + + The SKU supports rich presence. + + .. attribute:: discord_game_invites + + The SKU supports Discord game invites. + + .. attribute:: spectator_mode + + The SKU supports spectator mode. + + .. attribute:: controller_support + + The SKU supports controller support. + + .. attribute:: cloud_saves + + The SKU supports cloud saves. + + .. attribute:: online_coop + + The SKU supports online co-op. + + .. attribute:: secure_networking + + The SKU supports secure networking. + +.. class:: SKUGenre + + Represents the genre of a SKU. + + .. versionadded:: 2.0 + + .. attribute:: action + + The SKU is an action game. + + .. attribute:: action_adventure + + The SKU is an action-adventure game. + + .. attribute:: action_rpg + + The SKU is an action RPG. + + .. attribute:: adventure + + The SKU is an adventure game. + + .. attribute:: artillery + + The SKU is an artillery game. + + .. attribute:: baseball + + The SKU is a baseball game. + + .. attribute:: basketball + + The SKU is a basketball game. + + .. attribute:: billiards + + The SKU is a billiards game. + + .. attribute:: bowling + + The SKU is a bowling game. + + .. attribute:: boxing + + The SKU is a boxing game. + + .. attribute:: brawler + + The SKU is a brawler. + + .. attribute:: card_game + + The SKU is a card game. + + .. attribute:: driving_racing + + The SKU is a driving/racing game. + + .. attribute:: dual_joystick_shooter + + The SKU is a dual joystick shooter. + + .. attribute:: dungeon_crawler + + The SKU is a dungeon crawler. + + .. attribute:: education + + The SKU is an education game. + + .. attribute:: fighting + + The SKU is a fighting game. + + .. attribute:: fishing + + The SKU is a fishing game. + + .. attribute:: fitness + + The SKU is a fitness game. + + .. attribute:: flight_simulator + + The SKU is a flight simulator. + + .. attribute:: football + + The SKU is a football game. + + .. attribute:: four_x + + The SKU is a 4X game. + + .. attribute:: fps + + The SKU is a first-person shooter. + + .. attribute:: gambling + + The SKU is a gambling game. + + .. attribute:: golf + + The SKU is a golf game. + + .. attribute:: hack_and_slash + + The SKU is a hack-and-slash game. + + .. attribute:: hockey + + The SKU is a hockey game. + + .. attribute:: life_simulator + + The SKU is a life simulator. + + .. attribute:: light_gun + + The SKU is a light gun game. + + .. attribute:: massively_multiplayer + + The SKU is a massively multiplayer game. + + .. attribute:: music + + The SKU is a music game. - Represents a custom sticker created in a guild. + .. attribute:: party -.. class:: StickerFormatType + The SKU is a party game. - Represents the type of sticker images. + .. attribute:: pinball - .. versionadded:: 1.6 + The SKU is a pinball game. - .. attribute:: png + .. attribute:: platformer - Represents a sticker with a png image. + The SKU is a platformer. - .. attribute:: apng + .. attribute:: point_and_click - Represents a sticker with an apng image. + The SKU is a point-and-click game. - .. attribute:: lottie + .. attribute:: puzzle - Represents a sticker with a lottie image. + The SKU is a puzzle game. -.. class:: InviteTarget + .. attribute:: rpg - Represents the invite type for voice channel invites. + The SKU is an RPG. - .. versionadded:: 2.0 + .. attribute:: role_playing - .. attribute:: unknown + The SKU is a role-playing game. - The invite doesn't target anyone or anything. + .. attribute:: rts - .. attribute:: stream + The SKU is a real-time strategy game. - A stream invite that targets a user. + .. attribute:: sandbox - .. attribute:: embedded_application + The SKU is a sandbox game. - A stream invite that targets an embedded application. + .. attribute:: shooter -.. class:: VideoQualityMode + The SKU is a shooter. - Represents the camera video quality mode for voice channel participants. + .. attribute:: shoot_em_up + + The SKU is a shoot 'em up game. + + .. attribute:: simulation + + The SKU is a simulation game. + + .. attribute:: skateboarding_skating + + The SKU is a skateboarding/skating game. + + .. attribute:: snowboarding_skiing + + The SKU is a snowboarding/skiing game. + + .. attribute:: soccer + + The SKU is a soccer game. + + .. attribute:: sports + + The SKU is a sports game. + + .. attribute:: stealth + + The SKU is a stealth game. + + .. attribute:: strategy + + The SKU is a strategy game. + + .. attribute:: surfing_wakeboarding + + The SKU is a surfing/wakeboarding game. + + .. attribute:: survival + + The SKU is a survival game. + + .. attribute:: tennis + + The SKU is a tennis game. + + .. attribute:: third_person_shooter + + The SKU is a third-person shooter. + + .. attribute:: turn_based_strategy + + The SKU is a turn-based strategy game. + + .. attribute:: vehicular_combat + + The SKU is a vehicular combat game. + + .. attribute:: visual_novel + + The SKU is a visual novel. + + .. attribute:: wargame + + The SKU is a wargame. + + .. attribute:: wrestling + + The SKU is a wrestling game. + +.. class:: ContentRatingAgency + + Represents the content rating agency of a SKU. .. versionadded:: 2.0 - .. attribute:: auto + .. attribute:: esrb - Represents auto camera video quality. + The ESRB. - .. attribute:: full + .. attribute:: pegi - Represents full camera video quality. + The PEGI system. -.. class:: PrivacyLevel +.. class:: ESRBRating - Represents the privacy level of a stage instance or scheduled event. + Represents the ESRB rating of a SKU. .. versionadded:: 2.0 - .. attribute:: guild_only + .. attribute:: everyone - The stage instance or scheduled event is only accessible within the guild. + The SKU is rated E for everyone. -.. class:: NSFWLevel + .. attribute:: everyone_ten_plus - Represents the NSFW level of a guild. + The SKU is rated E10+ for everyone ten and older. + + .. attribute:: teen + + The SKU is rated T for teen. + + .. attribute:: mature + + The SKU is rated M for mature. + + .. attribute:: adults_only + + The SKU is rated AO for adults only. + + .. attribute:: rating_pending + + The SKU is pending a rating. + +.. class:: PEGIRating + + Represents the PEGI rating of a SKU. .. versionadded:: 2.0 - .. container:: operations + .. attribute:: three - .. describe:: x == y + The SKU is rated 3. - Checks if two NSFW levels are equal. - .. describe:: x != y + .. attribute:: seven - Checks if two NSFW levels are not equal. - .. describe:: x > y + The SKU is rated 7. - Checks if a NSFW level is higher than another. - .. describe:: x < y + .. attribute:: twelve - Checks if a NSFW level is lower than another. - .. describe:: x >= y + The SKU is rated 12. - Checks if a NSFW level is higher or equal to another. - .. describe:: x <= y + .. attribute:: sixteen - Checks if a NSFW level is lower or equal to another. + The SKU is rated 16. - .. attribute:: default + .. attribute:: eighteen - The guild has not been categorised yet. + The SKU is rated 18. - .. attribute:: explicit +.. class:: ESRBContentDescriptor - The guild contains NSFW content. + Represents an ESRB rating content descriptor. - .. attribute:: safe + .. versionadded:: 2.0 - The guild does not contain any NSFW content. + .. attribute:: alcohol_reference - .. attribute:: age_restricted + The SKU contains alcohol references. - The guild may contain NSFW content. + .. attribute:: animated_blood -.. class:: RelationshipType + The SKU contains animated blood. - Specifies the type of :class:`Relationship`. + .. attribute:: blood - .. attribute:: friend + The SKU contains blood. - You are friends with this user. + .. attribute:: blood_and_gore - .. attribute:: blocked + The SKU contains blood and gore. - You have blocked this user. + .. attribute:: cartoon_violence - .. attribute:: incoming_request + The SKU contains cartoon violence. - The user has sent you a friend request. + .. attribute:: comic_mischief - .. attribute:: outgoing_request + The SKU contains comic mischief. - You have sent a friend request to this user. + .. attribute:: crude_humor -.. class:: UserContentFilter + The SKU contains crude humor. - Represents the options found in ``Settings > Privacy & Safety > Safe Direct Messaging`` - in the Discord client. + .. attribute:: drug_reference - .. attribute:: all_messages + The SKU contains drug references. - Scan all direct messages from everyone. + .. attribute:: fantasy_violence - .. attribute:: non_friends + The SKU contains fantasy violence. - Scan all direct messages that aren't from friends. + .. attribute:: intense_violence - .. attribute:: disabled + The SKU contains intense violence. - Don't scan any direct messages. + .. attribute:: language -.. class:: FriendFlags + The SKU contains language. - Represents the options found in ``Settings > Privacy & Safety > Who Can Add You As A Friend`` - in the Discord client. + .. attribute:: lyrics - .. attribute:: noone + The SKU contains lyrics. - This allows no-one to add you as a friend. + .. attribute:: mature_humor - .. attribute:: mutual_guilds + The SKU contains mature humor. - This allows guild members to add you as a friend. + .. attribute:: nudity - .. attribute:: mutual_friends + The SKU contains nudity. - This allows friends of friends to add you as a friend. + .. attribute:: partial_nudity - .. attribute:: guild_and_friends + The SKU contains partial nudity. - This is a superset of :attr:`mutual_guilds` and :attr:`mutual_friends`. + .. attribute:: real_gambling - .. attribute:: everyone + The SKU contains real gambling. - This allows everyone to add you as a friend. + .. attribute:: sexual_content -.. class:: PremiumType + The SKU contains sexual content. - Represents the user's Discord Nitro subscription type. + .. attribute:: sexual_themes - .. container:: operations + The SKU contains sexual themes. - .. versionadded:: 2.0 + .. attribute:: sexual_violence - .. describe:: x == y + The SKU contains sexual violence. - Checks if two premium types are equal. - .. describe:: x != y + .. attribute:: simulated_gambling - Checks if two premium types are not equal. - .. describe:: x > y + The SKU contains simulated gambling. - Checks if a premium level is higher than another. - .. describe:: x < y + .. attribute:: strong_language - Checks if a premium level is lower than another. - .. describe:: x >= y + The SKU contains strong language. - Checks if a premium level is higher or equal to another. - .. describe:: x <= y + .. attribute:: strong_lyrics - Checks if a premium level is lower or equal to another. + The SKU contains strong lyrics. - .. attribute:: nitro + .. attribute:: strong_sexual_content - Represents the Discord Nitro with Nitro-exclusive games. + The SKU contains strong sexual content. - .. attribute:: nitro_classic + .. attribute:: suggestive_themes + + The SKU contains suggestive themes. + + .. attribute:: tobacco_reference + + The SKU contains tobacco references. + + .. attribute:: use_of_alcohol + + The SKU contains use of alcohol. + + .. attribute:: use_of_drugs + + The SKU contains use of drugs. + + .. attribute:: use_of_tobacco + + The SKU contains use of tobacco. + + .. attribute:: violence + + The SKU contains violence. + + .. attribute:: violent_references + + The SKU contains violent references. + + .. attribute:: in_game_purchases + + The SKU provides in-game purchases. + + .. attribute:: users_interact + + The SKU allows users to interact. + + .. attribute:: shares_location + + The SKU shares your location. + + .. attribute:: unrestricted_internet + + The SKU has unrestricted internet access. + + .. attribute:: mild_blood + + The SKU contains mild blood. + + .. attribute:: mild_cartoon_violence + + The SKU contains mild cartoon violence. + + .. attribute:: mild_fantasy_violence + + The SKU contains mild fantasy violence. + + .. attribute:: mild_language + + The SKU contains mild language. + + .. attribute:: mild_lyrics + + The SKU contains mild inappropriate lyrics. + + .. attribute:: mild_sexual_themes + + The SKU contains mild sexual themes. + + .. attribute:: mild_suggestive_themes + + The SKU contains mild suggestive themes. + + .. attribute:: mild_violence + + The SKU contains mild violence. + + .. attribute:: animated_violence + + The SKU contains animated violence. + +.. class:: PEGIContentDescriptor + + Represents a PEGI rating content descriptor. + + .. versionadded:: 2.0 + + .. attribute:: violence + + The SKU contains violence. + + .. attribute:: bad_language + + The SKU contains bad language. + + .. attribute:: fear - Represents the Discord Nitro with no Nitro-exclusive games. + The SKU instills fear. + + .. attribute:: gambling + + The SKU contains gambling. + + .. attribute:: sex + + The SKU contains sexual themes. + + .. attribute:: drugs + + The SKU contains drug references. + + .. attribute:: discrimination + + The SKU contains discrimination. + +.. class:: Distributor + + Represents the distributor of a third-party SKU on Discord. + + .. versionadded:: 2.0 + + .. attribute:: discord + + The SKU is distributed by Discord. + + .. attribute:: steam + + The SKU is distributed by Steam. + + .. attribute:: twitch + + The SKU is distributed by Twitch. + + .. attribute:: uplay + + The SKU is distributed by Ubisoft Connect. + + .. attribute:: battle_net + + The SKU is distributed by Battle.net. + + .. attribute:: origin + + The SKU is distributed by Origin. + + .. attribute:: gog + + The SKU is distributed by GOG. + + .. attribute:: epic_games + + The SKU is distributed by Epic Games. + + .. attribute:: google_play + + The SKU is distributed by Google Play. + +.. class:: OperatingSystem + + Represents the operating system of a SKU's system requirements. + + .. versionadded:: 2.0 + + .. attribute:: windows + + Represents Windows. + + .. attribute:: mac + + Represents macOS. + + .. attribute:: linux + + Represents Linux. .. class:: StickerAnimationOptions @@ -3088,7 +4552,6 @@ of :class:`enum.Enum`. The ``vi`` locale. - .. class:: MFALevel Represents the Multi-Factor Authentication requirement level of a guild. @@ -3186,9 +4649,17 @@ of :class:`enum.Enum`. The user must complete a captcha. - .. attribute:: accept_terms + .. attribute:: update_agreements + + The user must update their agreement of Discord's terms of service and privacy policy. + + .. attribute:: acknowledge_tos_update + + The user must acknowledge the update to Discord's terms of service. - The user must accept Discord's terms of service. + .. attribute:: none + + The user does not need to take any more actions. .. class:: ConnectionType @@ -3259,9 +4730,9 @@ of :class:`enum.Enum`. .. attribute:: steam The user has a Steam connection. - + .. attribute:: tiktok - + The user has a TikTok connection. .. attribute:: twitch @@ -3298,6 +4769,20 @@ of :class:`enum.Enum`. The connection is linked via desktop. +.. class:: GiftStyle + + Represents the special style of a gift. + + .. versionadded:: 2.0 + + .. attribute:: snowglobe + + The gift is a snowglobe. + + .. attribute:: box + + The gift is a box. + .. class:: InteractionType Specifies the type of :class:`Interaction`. @@ -3307,12 +4792,15 @@ of :class:`enum.Enum`. .. attribute:: application_command Represents a slash command interaction. + .. attribute:: component Represents a component based interaction, i.e. clicking a button. + .. attribute:: autocomplete Represents an autocomplete interaction. + .. attribute:: modal_submit Represents submission of a modal interaction. @@ -3326,9 +4814,11 @@ of :class:`enum.Enum`. .. attribute:: action_row Represents the group component which holds different components in a row. + .. attribute:: button Represents a button component. + .. attribute:: select Represents a select component. @@ -3346,15 +4836,19 @@ of :class:`enum.Enum`. .. attribute:: primary Represents a blurple button for the primary action. + .. attribute:: secondary Represents a grey button for the secondary action. + .. attribute:: success Represents a green button for a successful action. + .. attribute:: danger Represents a red button for a dangerous action. + .. attribute:: link Represents a link button. @@ -3362,18 +4856,23 @@ of :class:`enum.Enum`. .. attribute:: blurple An alias for :attr:`primary`. + .. attribute:: grey An alias for :attr:`secondary`. + .. attribute:: gray An alias for :attr:`secondary`. + .. attribute:: green An alias for :attr:`success`. + .. attribute:: red An alias for :attr:`danger`. + .. attribute:: url An alias for :attr:`link`. @@ -3387,9 +4886,11 @@ of :class:`enum.Enum`. .. attribute:: short Represents a short text box. + .. attribute:: paragraph Represents a long form text box. + .. attribute:: long An alias for :attr:`paragraph`. @@ -3403,9 +4904,11 @@ of :class:`enum.Enum`. .. attribute:: chat_input A slash command. + .. attribute:: user A user context menu command. + .. attribute:: message A message context menu command. @@ -3419,33 +4922,43 @@ of :class:`enum.Enum`. .. attribute:: subcommand A subcommand. + .. attribute:: subcommand_group A subcommand group. + .. attribute:: string A string parameter. + .. attribute:: integer A integer parameter. + .. attribute:: boolean A boolean parameter. + .. attribute:: user A user parameter. + .. attribute:: channel A channel parameter. + .. attribute:: role A role parameter. + .. attribute:: mentionable A mentionable parameter. + .. attribute:: number A number parameter. + .. attribute:: attachment An attachment parameter. @@ -4235,6 +5748,24 @@ User .. autoclass:: Note() :members: +Billing +~~~~~~~ + +.. attributetable:: BillingAddress + +.. autoclass:: BillingAddress() + :members: + +.. attributetable:: PaymentSource + +.. autoclass:: PaymentSource() + :members: + +.. attributetable:: PremiumUsage + +.. autoclass:: PremiumUsage() + :members: + Connection ~~~~~~~~~~ @@ -4249,9 +5780,50 @@ Connection .. autoclass:: PartialConnection() :members: -.. attributetable:: ConnectionMetadata +Relationship +~~~~~~~~~~~~~ + +.. attributetable:: Relationship + +.. autoclass:: Relationship() + :members: + +Settings +~~~~~~~~ + +.. attributetable:: UserSettings + +.. autoclass:: UserSettings() + :members: + +.. attributetable:: GuildSettings + +.. autoclass:: GuildSettings() + :members: + +.. attributetable:: ChannelSettings + +.. autoclass:: ChannelSettings() + :members: + +.. attributetable:: TrackingSettings + +.. autoclass:: TrackingSettings() + :members: + +.. attributetable:: EmailSettings + +.. autoclass:: EmailSettings() + :members: + +.. attributetable:: GuildFolder + +.. autoclass:: GuildFolder() + :members: + +.. attributetable:: MuteConfig -.. autoclass:: ConnectionMetadata() +.. autoclass:: MuteConfig() :members: Application @@ -4263,40 +5835,73 @@ Application :members: :inherited-members: +.. attributetable:: PartialApplication + +.. autoclass:: PartialApplication() + :members: + +.. attributetable:: ApplicationProfile + +.. autoclass:: ApplicationProfile() + :members: + .. attributetable:: ApplicationBot .. autoclass:: ApplicationBot() :members: :inherited-members: -.. attributetable:: PartialApplication +.. attributetable:: ApplicationExecutable -.. autoclass:: PartialApplication() +.. autoclass:: ApplicationExecutable() :members: -.. attributetable:: InteractionApplication +.. attributetable:: ApplicationInstallParams -.. autoclass:: InteractionApplication() +.. autoclass:: ApplicationInstallParams() :members: -.. attributetable:: ApplicationProfile +.. attributetable:: ApplicationAsset -.. autoclass:: ApplicationProfile() +.. autoclass:: ApplicationAsset() :members: -.. attributetable:: ApplicationCompany +.. attributetable:: ApplicationActivityStatistics -.. autoclass:: ApplicationCompany() +.. autoclass:: ApplicationActivityStatistics() :members: -.. attributetable:: ApplicationExecutable +.. attributetable:: ApplicationTester -.. autoclass:: ApplicationExecutable() +.. autoclass:: ApplicationTester() :members: -.. attributetable:: ApplicationInstallParams +.. attributetable:: EmbeddedActivityConfig -.. autoclass:: ApplicationInstallParams() +.. autoclass:: EmbeddedActivityConfig() + :members: + +ApplicationBranch +~~~~~~~~~~~~~~~~~ + +.. attributetable:: ApplicationBranch + +.. autoclass:: ApplicationBranch() + :members: + +.. attributetable:: ApplicationBuild + +.. autoclass:: ApplicationBuild() + :members: + +.. attributetable:: ManifestLabel + +.. autoclass:: ManifestLabel() + :members: + +.. attributetable:: Manifest + +.. autoclass:: Manifest() :members: Team @@ -4313,51 +5918,208 @@ Team :members: :inherited-members: -Relationship -~~~~~~~~~~~~~ +.. attributetable:: TeamPayout -.. attributetable:: Relationship +.. autoclass:: TeamPayout() + :members: -.. autoclass:: Relationship() +.. attributetable:: Company + +.. autoclass:: Company() :members: -Settings -~~~~~~~~ +.. attributetable:: EULA -.. attributetable:: UserSettings +.. autoclass:: EULA() + :members: -.. autoclass:: UserSettings() +Entitlement +~~~~~~~~~~~ + +.. attributetable:: Entitlement + +.. autoclass:: Entitlement() :members: -.. attributetable:: GuildSettings +.. attributetable:: EntitlementPayment -.. autoclass:: GuildSettings() +.. autoclass:: EntitlementPayment() :members: -.. attributetable:: ChannelSettings +.. attributetable:: Gift -.. autoclass:: ChannelSettings() +.. autoclass:: Gift() :members: -.. attributetable:: TrackingSettings +.. attributetable:: GiftBatch -.. autoclass:: TrackingSettings() +.. autoclass:: GiftBatch() :members: -.. attributetable:: EmailSettings +.. attributetable:: Achievement -.. autoclass:: EmailSettings() +.. autoclass:: Achievement() :members: -.. attributetable:: GuildFolder +Library +~~~~~~~ -.. autoclass:: GuildFolder() +.. attributetable:: LibraryApplication + +.. autoclass:: LibraryApplication() :members: -.. attributetable:: MuteConfig +.. attributetable:: LibrarySKU -.. autoclass:: MuteConfig() +.. autoclass:: LibrarySKU() + :members: + +Promotion +~~~~~~~~~ + +.. attributetable:: Promotion + +.. autoclass:: Promotion() + :members: + +.. attributetable:: PricingPromotion + +.. autoclass:: PricingPromotion() + :members: + +.. attributetable:: TrialOffer + +.. autoclass:: TrialOffer() + :members: + +Subscription +~~~~~~~~~~~~ + +.. attributetable:: Subscription + +.. autoclass:: Subscription() + :members: + +.. attributetable:: SubscriptionItem + +.. autoclass:: SubscriptionItem() + :members: + +.. attributetable:: SubscriptionDiscount + +.. autoclass:: SubscriptionDiscount() + :members: + +.. attributetable:: SubscriptionInvoice + +.. autoclass:: SubscriptionInvoice() + :members: + +.. attributetable:: SubscriptionInvoiceItem + +.. autoclass:: SubscriptionInvoiceItem() + :members: + +.. attributetable:: SubscriptionRenewalMutations + +.. autoclass:: SubscriptionRenewalMutations() + :members: + +.. attributetable:: SubscriptionTrial + +.. autoclass:: SubscriptionTrial() + :members: + +PremiumGuildSubscription +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: PremiumGuildSubscription + +.. autoclass:: PremiumGuildSubscription() + :members: + +.. attributetable:: PremiumGuildSubscriptionSlot + +.. autoclass:: PremiumGuildSubscriptionSlot() + :members: + +.. attributetable:: PremiumGuildSubscriptionCooldown + +.. autoclass:: PremiumGuildSubscriptionCooldown() + :members: + +SubscriptionPlan +~~~~~~~~~~~~~~~~~ + +.. attributetable:: SubscriptionPlan + +.. autoclass:: SubscriptionPlan() + :members: + +.. attributetable:: SubscriptionPlanPrices + +.. autoclass:: SubscriptionPlanPrices() + :members: + +Payment +~~~~~~~ + +.. attributetable:: Payment + +.. autoclass:: Payment() + :members: + +SKU +~~~~ + +.. attributetable:: SKU + +.. autoclass:: SKU() + :members: + +.. attributetable:: ThirdPartySKU + +.. autoclass:: ThirdPartySKU() + :members: + +.. attributetable:: SKUPrice + +.. autoclass:: SKUPrice() + :members: + +.. attributetable:: StoreListing + +.. autoclass:: StoreListing() + :members: + +.. attributetable:: StoreAsset + +.. autoclass:: StoreAsset() + :members: + +.. attributetable:: StoreNote + +.. autoclass:: StoreNote() + :members: + +.. attributetable:: ContentRating + +.. autoclass:: ContentRating() + :members: + +.. attributetable:: SystemRequirements + +.. autoclass:: SystemRequirements() + :members: + +Metadata +~~~~~~~~~ + +.. attributetable:: Metadata + +.. autoclass:: Metadata() :members: + :inherited-members: Asset ~~~~~ @@ -4991,6 +6753,16 @@ Flags .. autoclass:: ApplicationFlags() :members: +.. attributetable:: ApplicationDiscoveryFlags + +.. autoclass:: ApplicationDiscoveryFlags() + :members: + +.. attributetable:: LibraryApplicationFlags + +.. autoclass:: LibraryApplicationFlags() + :members: + .. attributetable:: ChannelFlags .. autoclass:: ChannelFlags() @@ -5001,14 +6773,24 @@ Flags .. autoclass:: SystemChannelFlags() :members: +.. attributetable:: GiftFlags + +.. autoclass:: GiftFlags() + :members: + .. attributetable:: MessageFlags .. autoclass:: MessageFlags() :members: -.. attributetable:: PublicUserFlags +.. attributetable:: PaymentFlags -.. autoclass:: PublicUserFlags() +.. autoclass:: PaymentFlags() + :members: + +.. attributetable:: PaymentSourceFlags + +.. autoclass:: PaymentSourceFlags() :members: .. attributetable:: PrivateUserFlags @@ -5017,6 +6799,11 @@ Flags :members: :inherited-members: +.. attributetable:: PublicUserFlags + +.. autoclass:: PublicUserFlags() + :members: + .. attributetable:: PremiumUsageFlags .. autoclass:: PremiumUsageFlags() @@ -5027,6 +6814,16 @@ Flags .. autoclass:: PurchasedFlags() :members: +.. attributetable:: PromotionFlags + +.. autoclass:: PromotionFlags() + :members: + +.. attributetable:: SKUFlags + +.. autoclass:: SKUFlags() + :members: + .. attributetable:: MemberCacheFlags .. autoclass:: MemberCacheFlags() diff --git a/docs/migrating.rst b/docs/migrating.rst index 9d47f39f7..c9776d181 100644 --- a/docs/migrating.rst +++ b/docs/migrating.rst @@ -263,24 +263,6 @@ In addition to this, :class:`Emoji` and :class:`PartialEmoji` now also share an The following were affected by this change: -- :attr:`AppInfo.cover_image` - - - ``AppInfo.cover_image`` (replaced by :attr:`AppInfo.cover_image.key `) - - ``AppInfo.cover_image_url`` (replaced by :attr:`AppInfo.cover_image`) - - - The new attribute may now be ``None``. - - - ``AppInfo.cover_image_url_as`` (replaced by :meth:`AppInfo.cover_image.replace `) - -- :attr:`AppInfo.icon` - - - ``AppInfo.icon`` (replaced by :attr:`AppInfo.icon.key `) - - ``AppInfo.icon_url`` (replaced by :attr:`AppInfo.icon`) - - - The new attribute may now be ``None``. - - - ``AppInfo.icon_url_as`` (replaced by :meth:`AppInfo.icon.replace `) - - :class:`AuditLogDiff` - :attr:`AuditLogDiff.avatar` is now of :class:`Asset` type. @@ -1028,17 +1010,13 @@ The following have been removed: - There is no replacement for this one. The current API version no longer provides enough data for this to be possible. -- ``AppInfo.summary`` - - - There is no replacement for this one. The current API version no longer provides this field. - - ``User.permissions_in`` and ``Member.permissions_in`` - Use :meth:`abc.GuildChannel.permissions_for` instead. - ``guild_subscriptions`` parameter from :class:`Client` constructor - - The current API version no longer provides this functionality. Use ``intents`` parameter instead. + - The current API version no longer provides this functionality. - ``guild_subscription_options`` parameter from :class:`Client` constructor @@ -1068,13 +1046,6 @@ The following have been removed: - ``region`` parameter from :meth:`Client.create_guild` - ``region`` parameter from :meth:`Template.create_guild` - ``region`` parameter from :meth:`Guild.edit` -- ``on_private_channel_create`` event - - - Discord API no longer sends channel create event for DMs. - -- ``on_private_channel_delete`` event - - - Discord API no longer sends channel create event for DMs. - The undocumented private ``on_socket_response`` event @@ -1092,29 +1063,6 @@ The following changes have been made: - :func:`utils.resolve_invite` now returns a :class:`ResolvedInvite` class. - :func:`utils.oauth_url` now defaults to ``bot`` and ``applications.commands`` scopes when not given instead of just ``bot``. - :meth:`abc.Messageable.typing` can no longer be used as a regular (non-async) context manager. -- :attr:`Intents.emojis` is now an alias of :attr:`Intents.emojis_and_stickers`. - - This may affect code that iterates through ``(name, value)`` pairs in an instance of this class: - - .. code:: python - - # before - friendly_names = { - ..., - 'emojis': 'Emojis Intent', - ..., - } - for name, value in discord.Intents.all(): - print(f'{friendly_names[name]}: {value}') - - # after - friendly_names = { - ..., - 'emojis_and_stickers': 'Emojis Intent', - ..., - } - for name, value in discord.Intents.all(): - print(f'{friendly_names[name]}: {value}') - ``created_at`` is no longer part of :class:`abc.Snowflake`. diff --git a/pyproject.toml b/pyproject.toml index 82bb19f77..069a10565 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ exclude = [ ] reportUnnecessaryTypeIgnoreComment = "warning" reportUnusedImport = "error" +reportShadowedImports = false pythonVersion = "3.8" typeCheckingMode = "basic"