diff --git a/changes/961.feature.md b/changes/961.feature.md new file mode 100644 index 0000000000..4a51bf8565 --- /dev/null +++ b/changes/961.feature.md @@ -0,0 +1 @@ +Add URL methods and properties for rich presence assets. diff --git a/hikari/impl/entity_factory.py b/hikari/impl/entity_factory.py index 4ce19b235d..cfedc4e803 100644 --- a/hikari/impl/entity_factory.py +++ b/hikari/impl/entity_factory.py @@ -2610,6 +2610,7 @@ def deserialize_member_presence( # noqa: CFQ001 - Max function length if "assets" in activity_payload: assets_payload = activity_payload["assets"] assets = presence_models.ActivityAssets( + application_id=application_id, large_image=assets_payload.get("large_image"), large_text=assets_payload.get("large_text"), small_image=assets_payload.get("small_image"), diff --git a/hikari/internal/routes.py b/hikari/internal/routes.py index bccb87a70c..674d2cd02c 100644 --- a/hikari/internal/routes.py +++ b/hikari/internal/routes.py @@ -548,6 +548,7 @@ def compile_to_file( CDN_APPLICATION_ICON: typing.Final[CDNRoute] = CDNRoute("/app-icons/{application_id}/{hash}", {PNG, *JPEG_JPG, WEBP}) CDN_APPLICATION_COVER: typing.Final[CDNRoute] = CDNRoute("/app-assets/{application_id}/{hash}", {PNG, *JPEG_JPG, WEBP}) +CDN_APPLICATION_ASSET: typing.Final[CDNRoute] = CDNRoute("/app-assets/{application_id}/{hash}", {PNG, *JPEG_JPG, WEBP}) CDN_ACHIEVEMENT_ICON: typing.Final[CDNRoute] = CDNRoute( "/app-assets/{application_id}/achievements/{achievement_id}/icons/{hash}", {PNG, *JPEG_JPG, WEBP} ) diff --git a/hikari/presences.py b/hikari/presences.py index 67fd1b555d..8c03e74c6d 100644 --- a/hikari/presences.py +++ b/hikari/presences.py @@ -42,9 +42,12 @@ import attr +from hikari import files from hikari import snowflakes +from hikari import urls from hikari.internal import attr_extensions from hikari.internal import enums +from hikari.internal import routes if typing.TYPE_CHECKING: import datetime @@ -118,11 +121,16 @@ class ActivityParty: """Maximum size of this party, if applicable.""" +_DYNAMIC_URLS = {"mp": urls.MEDIA_PROXY_URL + "/{}"} + + @attr_extensions.with_copy @attr.define(hash=False, kw_only=True, weakref_slot=False) class ActivityAssets: """Used to represent possible assets for an activity.""" + _application_id: typing.Optional[snowflakes.Snowflake] = attr.field(repr=False) + large_image: typing.Optional[str] = attr.field(repr=False) """The ID of the asset's large image, if set.""" @@ -135,6 +143,113 @@ class ActivityAssets: small_text: typing.Optional[str] = attr.field(repr=True) """The text that'll appear when hovering over the small image, if set.""" + def _make_asset_url(self, asset: typing.Optional[str], ext: str, size: int) -> typing.Optional[files.URL]: + if asset is None: + return None + + try: + resource, identifier = asset.split(":", 1) + return files.URL(url=_DYNAMIC_URLS[resource].format(identifier)) + + except KeyError: + raise RuntimeError("Unknown asset type") from None + + except ValueError: + assert self._application_id is not None + return routes.CDN_APPLICATION_ASSET.compile_to_file( + urls.CDN_URL, + application_id=self._application_id, + hash=asset, + size=size, + file_format=ext, + ) + + @property + def large_image_url(self) -> typing.Optional[files.URL]: + """Large image asset URL. + + !!! note + This will be `builtins.None` if no large image asset exists or if the + asset's dymamic URL (indicated by a `{name}:` prefix) is not known. + """ + try: + return self.make_large_image_url() + + except RuntimeError: + return None + + def make_large_image_url(self, *, ext: str = "png", size: int = 4096) -> typing.Optional[files.URL]: + """Generate the large image asset URL for this application. + + !!! note + `ext` and `size` are ignored for images hosted outside of Discord + or on Discord's media proxy. + + Parameters + ---------- + ext : builtins.str + The extension to use for this URL, defaults to `png`. + Supports `png`, `jpeg`, `jpg` and `webp`. + size : builtins.int + The size to set for the URL, defaults to `4096`. + Can be any power of two between 16 and 4096. + + Returns + ------- + typing.Optional[hikari.files.URL] + The URL, or `builtins.None` if no icon exists. + + Raises + ------ + builtins.ValueError + If the size is not an integer power of 2 between 16 and 4096 + (inclusive). + builtins.RuntimeError + If `ActivityAssets.large_image` points towards an unknown asset type. + """ + return self._make_asset_url(self.large_image, ext, size) + + @property + def small_image_url(self) -> typing.Optional[files.URL]: + """Small image asset URL. + + !!! note + This will be `builtins.None` if no large image asset exists or if the + asset's dymamic URL (indicated by a `{name}:` prefix) is not known. + """ + try: + return self.make_small_image_url() + + except RuntimeError: + return None + + def make_small_image_url(self, *, ext: str = "png", size: int = 4096) -> typing.Optional[files.URL]: + """Generate the small image asset URL for this application. + + Parameters + ---------- + ext : builtins.str + The extension to use for this URL, defaults to `png`. + Supports `png`, `jpeg`, `jpg` and `webp`. + size : builtins.int + The size to set for the URL, defaults to `4096`. + Can be any power of two between 16 and 4096. + + Returns + ------- + typing.Optional[hikari.files.URL] + The URL, or `builtins.None` if no icon exists. + + Raises + ------ + builtins.ValueError + If the size is not an integer power of 2 between 16 and 4096 + (inclusive). + builtins.RuntimeError + If `ActivityAssets.small_image` points towards an unknown asset type. + """ + return self._make_asset_url(self.small_image, ext, size) + @attr_extensions.with_copy @attr.define(hash=False, kw_only=True, weakref_slot=False) diff --git a/hikari/urls.py b/hikari/urls.py index fa0b67d35d..b58fbb5c7d 100644 --- a/hikari/urls.py +++ b/hikari/urls.py @@ -39,3 +39,6 @@ CDN_URL: typing.Final[str] = "https://cdn.discordapp.com" """The CDN URL.""" + +MEDIA_PROXY_URL: typing.Final[str] = "https://media.discordapp.net" +"""The media proxy URL.""" diff --git a/tests/hikari/hikari_test_helpers.py b/tests/hikari/hikari_test_helpers.py index bb2b3b15a7..6e5848377f 100644 --- a/tests/hikari/hikari_test_helpers.py +++ b/tests/hikari/hikari_test_helpers.py @@ -49,16 +49,11 @@ # condition, and thus acceptable to terminate the test and fail it. REASONABLE_TIMEOUT_AFTER = 10 -_stubbed_classes = {} - - -def _stub_init(self, kwargs: typing.Mapping[str, typing.Any]): - for attr, value in kwargs.items(): - setattr(self, attr, value) +_T = typing.TypeVar("_T") def mock_class_namespace( - klass, + klass: typing.Type[_T], /, *, init_: bool = True, @@ -66,7 +61,7 @@ def mock_class_namespace( implement_abstract_methods_: bool = True, rename_impl_: bool = True, **namespace: typing.Any, -): +) -> typing.Type[_T]: """Get a version of a class with the provided namespace fields set as class attributes.""" if slots_ or slots_ is None and hasattr(klass, "__slots__"): namespace["__slots__"] = () diff --git a/tests/hikari/impl/test_entity_factory.py b/tests/hikari/impl/test_entity_factory.py index 7469617ca5..173f8e8c4a 100644 --- a/tests/hikari/impl/test_entity_factory.py +++ b/tests/hikari/impl/test_entity_factory.py @@ -4634,6 +4634,7 @@ def test_deserialize_member_presence( assert isinstance(activity.party, presence_models.ActivityParty) # ActivityAssets assert activity.assets is not None + assert activity.assets._application_id is activity.application_id assert activity.assets.large_image == "34234234234243" assert activity.assets.large_text == "LARGE TEXT" assert activity.assets.small_image == "3939393" diff --git a/tests/hikari/test_presences.py b/tests/hikari/test_presences.py index 1af555813a..fd1c039327 100644 --- a/tests/hikari/test_presences.py +++ b/tests/hikari/test_presences.py @@ -23,9 +23,12 @@ import mock import pytest +from hikari import files from hikari import presences from hikari import snowflakes +from hikari import urls from hikari.impl import bot +from hikari.internal import routes @pytest.fixture() @@ -33,9 +36,188 @@ def mock_app(): return mock.Mock(spec_set=bot.GatewayBot) -def test_Activity_str_operator(): - activity = presences.Activity(name="something", type=presences.ActivityType(1)) - assert str(activity) == "something" +class TestActivityAssets: + def test_large_image_url_property(self): + asset = presences.ActivityAssets( + application_id=None, + large_image=None, + large_text=None, + small_image=None, + small_text=None, + ) + + with mock.patch.object(presences.ActivityAssets, "make_large_image_url") as make_large_image_url: + result = asset.large_image_url + + assert result is make_large_image_url.return_value + make_large_image_url.assert_called_once_with() + + def test_large_image_url_property_when_runtime_error(self): + asset = presences.ActivityAssets( + application_id=None, + large_image=None, + large_text=None, + small_image=None, + small_text=None, + ) + + with mock.patch.object( + presences.ActivityAssets, "make_large_image_url", side_effect=RuntimeError + ) as make_large_image_url: + result = asset.large_image_url + + assert result is None + make_large_image_url.assert_called_once_with() + + def test_make_large_image_url(self): + asset = presences.ActivityAssets( + application_id=45123123, + large_image="541sdfasdasd", + large_text=None, + small_image=None, + small_text=None, + ) + + with mock.patch.object(routes, "CDN_APPLICATION_ASSET") as route: + assert asset.make_large_image_url(ext="fa", size=3121) is route.compile_to_file.return_value + + route.compile_to_file.assert_called_once_with( + urls.CDN_URL, + application_id=45123123, + hash="541sdfasdasd", + size=3121, + file_format="fa", + ) + + def test_make_large_image_url_when_no_hash(self): + asset = presences.ActivityAssets( + application_id=None, + large_image=None, + large_text=None, + small_image=None, + small_text=None, + ) + + assert asset.make_large_image_url() is None + + @pytest.mark.parametrize( + ("asset_hash", "expected"), [("mp:541sdfasdasd", "https://media.discordapp.net/541sdfasdasd")] + ) + def test_make_large_image_url_when_dynamic_url(self, asset_hash: str, expected: str): + asset = presences.ActivityAssets( + application_id=None, + large_image=asset_hash, + large_text=None, + small_image=None, + small_text=None, + ) + + assert asset.make_large_image_url() == files.URL(expected) + + def test_make_large_image_url_when_unknown_dynamic_url(self): + asset = presences.ActivityAssets( + application_id=None, + large_image="uwu:nou", + large_text=None, + small_image=None, + small_text=None, + ) + + with pytest.raises(RuntimeError, match="Unknown asset type"): + asset.make_large_image_url() + + def test_small_image_url_property(self): + asset = presences.ActivityAssets( + application_id=None, + large_image=None, + large_text=None, + small_image=None, + small_text=None, + ) + + with mock.patch.object(presences.ActivityAssets, "make_small_image_url") as make_small_image_url: + result = asset.small_image_url + + assert result is make_small_image_url.return_value + make_small_image_url.assert_called_once_with() + + def test_small_image_url_property_when_runtime_error(self): + asset = presences.ActivityAssets( + application_id=None, + large_image=None, + large_text=None, + small_image=None, + small_text=None, + ) + + with mock.patch.object( + presences.ActivityAssets, "make_small_image_url", side_effect=RuntimeError + ) as make_small_image_url: + result = asset.small_image_url + + assert result is None + make_small_image_url.assert_called_once_with() + + def test_make_small_image_url(self): + asset = presences.ActivityAssets( + application_id=123321, + large_image=None, + large_text=None, + small_image="aseqwsdas", + small_text=None, + ) + + with mock.patch.object(routes, "CDN_APPLICATION_ASSET") as route: + assert asset.make_small_image_url(ext="eat", size=123312) is route.compile_to_file.return_value + + route.compile_to_file.assert_called_once_with( + urls.CDN_URL, + application_id=123321, + hash="aseqwsdas", + size=123312, + file_format="eat", + ) + + def test_make_small_image_url_when_no_hash(self): + asset = presences.ActivityAssets( + application_id=None, + large_image=None, + large_text=None, + small_image=None, + small_text=None, + ) + + assert asset.make_small_image_url() is None + + @pytest.mark.parametrize(("asset_hash", "expected"), [("mp:4123fdssdf", "https://media.discordapp.net/4123fdssdf")]) + def test_make_small_image_url_when_dynamic_url(self, asset_hash: str, expected: str): + asset = presences.ActivityAssets( + application_id=None, + large_image=None, + large_text=None, + small_image=asset_hash, + small_text=None, + ) + + assert asset.make_small_image_url() == files.URL(expected) + + def test_make_small_image_url_when_unknown_dynamic_url(self): + asset = presences.ActivityAssets( + application_id=None, + large_image=None, + large_text=None, + small_image="meow:nyaa", + small_text=None, + ) + + with pytest.raises(RuntimeError, match="Unknown asset type"): + asset.make_small_image_url() + + +class TestActivity: + def test_str_operator(self): + activity = presences.Activity(name="something", type=presences.ActivityType(1)) + assert str(activity) == "something" class TestMemberPresence: