diff --git a/discord/application.py b/discord/application.py index 0b653abef..f056c6d37 100644 --- a/discord/application.py +++ b/discord/application.py @@ -27,6 +27,7 @@ from __future__ import annotations from datetime import datetime from typing import ( TYPE_CHECKING, + Any, AsyncIterator, Collection, List, @@ -94,6 +95,7 @@ if TYPE_CHECKING: Manifest as ManifestPayload, ManifestLabel as ManifestLabelPayload, PartialApplication as PartialApplicationPayload, + UnverifiedApplication as UnverifiedApplicationPayload, WhitelistedUser as WhitelistedUserPayload, ) from .types.user import PartialUser as PartialUserPayload @@ -117,6 +119,7 @@ __all__ = ( 'PartialApplication', 'Application', 'IntegrationApplication', + 'UnverifiedApplication', ) MISSING = utils.MISSING @@ -1282,7 +1285,7 @@ class ApplicationBuild(Hashable): 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) + await self._state.http.upload_to_cloud(url['url'], file, file.b64_md5 if hash else None) async def publish(self) -> None: """|coro| @@ -3681,3 +3684,68 @@ class IntegrationApplication(Hashable): 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 UnverifiedApplication: + """Represents an unverified application (a game not detected by the Discord client) that has been reported to Discord. + + .. 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.1 + + Attributes + ----------- + name: :class:`str` + The name of the application. + hash: :class:`str` + The hash of the application. + missing_data: List[:class:`str`] + Data missing from the unverified application report. + + .. note:: + + :meth:`Client.report_unverified_application` will automatically + upload the unverified application's icon, if missing. + """ + + __slots__ = ('name', 'hash', 'missing_data') + + def __init__(self, *, data: UnverifiedApplicationPayload): + self.name: str = data['name'] + self.hash: str = data['hash'] + self.missing_data: List[str] = data.get('missing_data', []) + + def __repr__(self) -> str: + return f'' + + def __hash__(self) -> int: + return hash(self.hash) + + def __str__(self) -> str: + return self.name + + def __eq__(self, other: Any) -> bool: + if isinstance(other, UnverifiedApplication): + return self.hash == other.hash + return NotImplemented + + def __ne__(self, other: Any) -> bool: + if isinstance(other, UnverifiedApplication): + return self.hash != other.hash + return NotImplemented diff --git a/discord/client.py b/discord/client.py index 0e0b78e04..8b7980b4a 100644 --- a/discord/client.py +++ b/discord/client.py @@ -70,7 +70,7 @@ from .utils import MISSING from .object import Object, OLDEST_OBJECT from .backoff import ExponentialBackoff from .webhook import Webhook -from .application import Application, ApplicationActivityStatistics, Company, EULA, PartialApplication +from .application import Application, ApplicationActivityStatistics, Company, EULA, PartialApplication, UnverifiedApplication from .stage_instance import StageInstance from .threads import Thread from .sticker import GuildSticker, StandardSticker, StickerPack, _sticker_factory @@ -102,11 +102,12 @@ if TYPE_CHECKING: from .voice_client import VoiceProtocol from .settings import GuildSettings from .billing import BillingAddress - from .enums import PaymentGateway, RequiredActionType + from .enums import Distributor, OperatingSystem, PaymentGateway, RequiredActionType from .metadata import MetadataObject from .permissions import Permissions from .read_state import ReadState from .tutorial import Tutorial + from .file import File from .types.snowflake import Snowflake as _Snowflake PrivateChannel = Union[DMChannel, GroupChannel] @@ -780,7 +781,6 @@ class Client: aiohttp.ClientError, asyncio.TimeoutError, ) as exc: - self.dispatch('disconnect') if not reconnect: await self.close() @@ -4883,3 +4883,66 @@ class Client: Leaving the active developer program failed. """ await self._connection.http.unenroll_active_developer() + + async def report_unverified_application( + self, + name: str, + *, + icon: File, + os: OperatingSystem, + executable: str = MISSING, + publisher: str = MISSING, + distributor: Distributor = MISSING, + sku: str = MISSING, + ) -> UnverifiedApplication: + """|coro| + + Reports an unverified application (a game not detected by the Discord client) to Discord. + + If missing, this also uploads the application icon to Discord. + + .. versionadded:: 2.1 + + Parameters + ----------- + name: :class:`str` + The name of the application. + icon: :class:`.File` + The icon of the application. + os: :class:`.OperatingSystem` + The operating system the application is found on. + executable: :class:`str` + The executable of the application. + publisher: :class:`str` + The publisher of the application. + distributor: :class:`.Distributor` + The distributor of the application SKU. + sku: :class:`str` + The SKU of the application. + + Raises + ------- + HTTPException + Reporting the unverified application failed. + + Returns + ------- + :class:`.UnverifiedApplication` + The reported unverified application. + """ + state = self._connection + data = await state.http.report_unverified_application( + name=name, + icon_hash=icon.md5.hexdigest(), + os=str(os), + executable=executable, + publisher=publisher, + distributor=str(distributor) if distributor else None, + sku=sku, + ) + + app = UnverifiedApplication(data=data) + if 'icon' in app.missing_data: + icon_data = utils._bytes_to_base64_data(icon.fp.read()) + await state.http.upload_unverified_application_icon(app.name, app.hash, icon_data) + return app diff --git a/discord/enums.py b/discord/enums.py index f7eccc03f..a52f68482 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -1330,21 +1330,23 @@ class SKUGenre(Enum): # There are tons of different operating system/client enums in the API, # so we try to unify them here -# They're normalized as the numbered enum, and converted from the stringified enums +# They're normalized as the numbered enum, and converted from the stringified enum(s) class OperatingSystem(Enum): windows = 1 macos = 2 linux = 3 android = -1 - ios = -1 - unknown = -1 + ios = -2 + unknown = -3 @classmethod def from_string(cls, value: str) -> Self: lookup = { 'windows': cls.windows, + 'win32': cls.windows, 'macos': cls.macos, + 'darwin': cls.macos, 'linux': cls.linux, 'android': cls.android, 'ios': cls.ios, @@ -1352,6 +1354,20 @@ class OperatingSystem(Enum): } return lookup.get(value, create_unknown_value(cls, value)) + def to_string(self): + lookup = { + OperatingSystem.windows: 'win32', + OperatingSystem.macos: 'darwin', + OperatingSystem.linux: 'linux', + OperatingSystem.android: 'android', + OperatingSystem.ios: 'ios', + OperatingSystem.unknown: 'unknown', + } + return lookup[self] + + def __str__(self): + return self.to_string() + class ContentRatingAgency(Enum): esrb = 1 @@ -1454,6 +1470,9 @@ class Distributor(Enum): epic_games = 'epic' google_play = 'google_play' + def __str__(self): + return self.value + class EntitlementType(Enum): purchase = 1 diff --git a/discord/file.py b/discord/file.py index 965116470..37854256d 100644 --- a/discord/file.py +++ b/discord/file.py @@ -162,12 +162,16 @@ class File(_FileBase): super().__init__(filename, spoiler=spoiler, description=description) @cached_slot_property('_cs_md5') - def md5(self) -> str: + def md5(self): try: - return b64encode(md5(self.fp.read()).digest()).decode('utf-8') + return md5(self.fp.read()) finally: self.reset() + @property + def b64_md5(self) -> str: + return b64encode(self.md5.digest()).decode('ascii') + @cached_slot_property('_cs_size') def size(self) -> int: return os.fstat(self.fp.fileno()).st_size diff --git a/discord/http.py b/discord/http.py index 604a87432..b247f9342 100644 --- a/discord/http.py +++ b/discord/http.py @@ -3483,7 +3483,7 @@ class HTTPClient: file.filename = id data = {'id': file.filename} if hash: - data['md5_hash'] = file.md5 + data['md5_hash'] = file.b64_md5 payload['files'].append(data) @@ -4559,6 +4559,46 @@ class HTTPClient: ) ) + # Unverified Applications + + def report_unverified_application( + self, + name: str, + icon_hash: str, + os: str, + *, + executable: Optional[str] = None, + publisher: Optional[str] = None, + distributor: Optional[str] = None, + sku: Optional[str] = None, + ) -> Response[application.UnverifiedApplication]: + payload = { + 'report_version': 3, + 'name': name, + 'icon': icon_hash, + 'os': os, + } + if executable is not None: + payload['executable'] = executable + if publisher: + payload['publisher'] = publisher + if distributor: + payload['distributor_application'] = { + 'distributor': distributor, + 'sku': sku or '', + } + + return self.request(Route('POST', '/unverified-applications'), json=payload) + + def upload_unverified_application_icon(self, name: str, hash: str, icon: str) -> Response[None]: + payload = { + 'application_name': name, + 'application_hash': hash, + 'icon': icon, + } + + return self.request(Route('POST', '/unverified-applications/icons'), json=payload) + # Recent Mentions def get_recent_mentions( diff --git a/discord/types/application.py b/discord/types/application.py index 6efd349a9..d9f9eef46 100644 --- a/discord/types/application.py +++ b/discord/types/application.py @@ -284,3 +284,9 @@ class PartialRoleConnection(TypedDict): class RoleConnection(PartialRoleConnection): application: RoleConnectionApplication application_metadata: List[RoleConnectionMetadata] + + +class UnverifiedApplication(TypedDict): + name: str + hash: str + missing_data: List[str] diff --git a/docs/api.rst b/docs/api.rst index 017f7c500..a6c0a1048 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -6705,6 +6705,11 @@ Application .. autoclass:: EmbeddedActivityConfig() :members: +.. attributetable:: UnverifiedApplication + +.. autoclass:: UnverifiedApplication() + :members: + ApplicationBranch ~~~~~~~~~~~~~~~~~