From 4b3b6aeb45b4b6cfd5c39bc6c66b7e890a6d8358 Mon Sep 17 00:00:00 2001 From: Slipstream Date: Sun, 15 Jun 2025 20:39:14 -0600 Subject: [PATCH] Add Asset model and avatar helpers (#119) --- README.md | 12 ++ disagreement/__init__.py | 2 + disagreement/asset.py | 51 +++++++++ disagreement/http.py | 10 +- disagreement/models.py | 230 +++++++++++++++++++++++++++++++++++---- tests/test_asset.py | 14 +++ 6 files changed, 292 insertions(+), 27 deletions(-) create mode 100644 disagreement/asset.py create mode 100644 tests/test_asset.py diff --git a/README.md b/README.md index 26e5c55..b2c56ab 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ A Python library for interacting with the Discord API, with a focus on bot devel - `Message.jump_url` property for quick links to messages - Built-in caching layer - `Guild.me` property to access the bot's member object +- Easy CDN asset handling via the `Asset` model - Experimental voice support - Helpful error handling utilities @@ -126,6 +127,17 @@ client = disagreement.Client( This dictionary is used whenever ``send_message`` or helpers like ``Message.reply`` are called without an explicit ``allowed_mentions`` argument. +### Working With Assets + +Properties like ``User.avatar`` and ``Guild.icon`` return :class:`disagreement.Asset` objects. +Use ``read`` to get the bytes or ``save`` to write them to disk. + +```python +user = await client.fetch_user(123) +data = await user.avatar.read() +await user.avatar.save("avatar.png") +``` + ### Defining Subcommands with `AppCommandGroup` ```python diff --git a/disagreement/__init__.py b/disagreement/__init__.py index ca28c17..fc44bfd 100644 --- a/disagreement/__init__.py +++ b/disagreement/__init__.py @@ -15,6 +15,7 @@ __copyright__ = "Copyright 2025 Slipstream" __version__ = "0.8.1" from .client import Client, AutoShardedClient +from .asset import Asset from .models import ( Message, User, @@ -125,6 +126,7 @@ import logging __all__ = [ "Client", "AutoShardedClient", + "Asset", "Message", "User", "Reaction", diff --git a/disagreement/asset.py b/disagreement/asset.py new file mode 100644 index 0000000..1254f59 --- /dev/null +++ b/disagreement/asset.py @@ -0,0 +1,51 @@ +"""Utility class for Discord CDN assets.""" + +from __future__ import annotations + +import os +from typing import IO, Optional, Union, TYPE_CHECKING + +import aiohttp # pylint: disable=import-error + +if TYPE_CHECKING: + from .client import Client + + +class Asset: + """Represents a CDN asset such as an avatar or icon.""" + + def __init__(self, url: str, client_instance: Optional["Client"] = None) -> None: + self.url = url + self._client = client_instance + + async def read(self) -> bytes: + """Read the asset's bytes.""" + + session: Optional[aiohttp.ClientSession] = None + if self._client is not None: + await self._client._http._ensure_session() # type: ignore[attr-defined] + session = self._client._http._session # type: ignore[attr-defined] + if session is None: + session = aiohttp.ClientSession() + close = True + else: + close = False + async with session.get(self.url) as resp: + data = await resp.read() + if close: + await session.close() + return data + + async def save(self, fp: Union[str, os.PathLike[str], IO[bytes]]) -> None: + """Save the asset to the given file path or file-like object.""" + + data = await self.read() + if isinstance(fp, (str, os.PathLike)): + path = os.fspath(fp) + with open(path, "wb") as file: + file.write(data) + else: + fp.write(data) + + def __repr__(self) -> str: + return f"" diff --git a/disagreement/http.py b/disagreement/http.py index 3751585..2cb9bca 100644 --- a/disagreement/http.py +++ b/disagreement/http.py @@ -788,11 +788,6 @@ class HTTPClient: return Webhook(data) - async def get_webhook(self, webhook_id: "Snowflake") -> Dict[str, Any]: - """Fetches a webhook by ID.""" - - return await self.request("GET", f"/webhooks/{webhook_id}") - async def edit_webhook( self, webhook_id: "Snowflake", payload: Dict[str, Any] ) -> "Webhook": @@ -818,7 +813,10 @@ class HTTPClient: if token is not None: endpoint += f"/{token}" use_auth = False - data = await self.request("GET", endpoint, use_auth_header=use_auth) + if use_auth: + data = await self.request("GET", endpoint) + else: + data = await self.request("GET", endpoint, use_auth_header=False) from .models import Webhook return Webhook(data) diff --git a/disagreement/models.py b/disagreement/models.py index 98cbba2..de5d0af 100644 --- a/disagreement/models.py +++ b/disagreement/models.py @@ -1,6 +1,8 @@ -""" -Data models for Discord objects. -""" +""" +Data models for Discord objects. +""" + +from __future__ import annotations import asyncio import datetime @@ -47,13 +49,14 @@ from .enums import ( # These enums will need to be defined in disagreement/enum from .permissions import Permissions -if TYPE_CHECKING: - from .client import Client # For type hinting to avoid circular imports - from .enums import OverwriteType # For PermissionOverwrite model - from .ui.view import View - from .interactions import Snowflake - from .typing import Typing - from .shard_manager import Shard +if TYPE_CHECKING: + from .client import Client # For type hinting to avoid circular imports + from .enums import OverwriteType # For PermissionOverwrite model + from .ui.view import View + from .interactions import Snowflake + from .typing import Typing + from .shard_manager import Shard + from .asset import Asset # Forward reference Message if it were used in type hints before its definition # from .models import Message # Not needed as Message is defined before its use in TextChannel.send etc. @@ -71,7 +74,12 @@ class User: self.username: Optional[str] = data.get("username") self.discriminator: Optional[str] = data.get("discriminator") self.bot: bool = data.get("bot", False) - self.avatar: Optional[str] = data.get("avatar") + avatar_hash = data.get("avatar") + self._avatar: Optional[str] = ( + f"https://cdn.discordapp.com/avatars/{self.id}/{avatar_hash}.png" + if avatar_hash + else None + ) @property def mention(self) -> str: @@ -83,6 +91,25 @@ class User: disc = self.discriminator or "????" return f"" + @property + def avatar(self) -> Optional["Asset"]: + """Return the user's avatar as an :class:`Asset`.""" + + if self._avatar: + from .asset import Asset + + return Asset(self._avatar, self._client) + return None + + @avatar.setter + def avatar(self, value: Optional[Union[str, "Asset"]]) -> None: + if isinstance(value, str): + self._avatar = value + elif value is None: + self._avatar = None + else: + self._avatar = value.url + async def send( self, content: Optional[str] = None, @@ -780,7 +807,12 @@ class Role: self.name: str = data["name"] self.color: int = data["color"] self.hoist: bool = data["hoist"] - self.icon: Optional[str] = data.get("icon") + icon_hash = data.get("icon") + self._icon: Optional[str] = ( + f"https://cdn.discordapp.com/role-icons/{self.id}/{icon_hash}.png" + if icon_hash + else None + ) self.unicode_emoji: Optional[str] = data.get("unicode_emoji") self.position: int = data["position"] self.permissions: str = data["permissions"] # String of bitwise permissions @@ -798,6 +830,23 @@ class Role: def __repr__(self) -> str: return f"" + @property + def icon(self) -> Optional["Asset"]: + if self._icon: + from .asset import Asset + + return Asset(self._icon, None) + return None + + @icon.setter + def icon(self, value: Optional[Union[str, "Asset"]]) -> None: + if isinstance(value, str): + self._icon = value + elif value is None: + self._icon = None + else: + self._icon = value.url + class Member(User): # Member inherits from User """Represents a Guild Member. @@ -826,7 +875,15 @@ class Member(User): # Member inherits from User ) # Pass user_data or data if user_data is empty self.nick: Optional[str] = data.get("nick") - self.avatar: Optional[str] = data.get("avatar") + avatar_hash = data.get("avatar") + if avatar_hash: + guild_id = data.get("guild_id") + if guild_id: + self._avatar = f"https://cdn.discordapp.com/guilds/{guild_id}/users/{self.id}/avatars/{avatar_hash}.png" + else: + self._avatar = ( + f"https://cdn.discordapp.com/avatars/{self.id}/{avatar_hash}.png" + ) self.roles: List[str] = data.get("roles", []) self.joined_at: str = data["joined_at"] self.premium_since: Optional[str] = data.get("premium_since") @@ -854,6 +911,25 @@ class Member(User): # Member inherits from User def __repr__(self) -> str: return f"" + @property + def avatar(self) -> Optional["Asset"]: + """Return the member's avatar as an :class:`Asset`.""" + + if self._avatar: + from .asset import Asset + + return Asset(self._avatar, self._client) + return None + + @avatar.setter + def avatar(self, value: Optional[Union[str, "Asset"]]) -> None: + if isinstance(value, str): + self._avatar = value + elif value is None: + self._avatar = None + else: + self._avatar = value.url + @property def display_name(self) -> str: """Return the nickname if set, otherwise the username.""" @@ -921,7 +997,6 @@ class Member(User): # Member inherits from User return max(role_objects, key=lambda r: r.position) @property - def guild_permissions(self) -> "Permissions": """Return the member's guild-level permissions.""" @@ -948,8 +1023,9 @@ class Member(User): # Member inherits from User return base - def voice(self) -> Optional["VoiceState"]: - """Return the member's cached voice state as a :class:`VoiceState`.""" + @property + def voice(self) -> Optional["VoiceState"]: + """Return the member's cached voice state as a :class:`VoiceState`.""" if self.voice_state is None: return None @@ -1210,9 +1286,24 @@ class Guild: ) self.id: str = data["id"] self.name: str = data["name"] - self.icon: Optional[str] = data.get("icon") - self.splash: Optional[str] = data.get("splash") - self.discovery_splash: Optional[str] = data.get("discovery_splash") + icon_hash = data.get("icon") + self._icon: Optional[str] = ( + f"https://cdn.discordapp.com/icons/{self.id}/{icon_hash}.png" + if icon_hash + else None + ) + splash_hash = data.get("splash") + self._splash: Optional[str] = ( + f"https://cdn.discordapp.com/splashes/{self.id}/{splash_hash}.png" + if splash_hash + else None + ) + discovery_hash = data.get("discovery_splash") + self._discovery_splash: Optional[str] = ( + f"https://cdn.discordapp.com/discovery-splashes/{self.id}/{discovery_hash}.png" + if discovery_hash + else None + ) self.owner: Optional[bool] = data.get("owner") self.owner_id: str = data["owner_id"] self.permissions: Optional[str] = data.get("permissions") @@ -1249,7 +1340,12 @@ class Guild: self.max_members: Optional[int] = data.get("max_members") self.vanity_url_code: Optional[str] = data.get("vanity_url_code") self.description: Optional[str] = data.get("description") - self.banner: Optional[str] = data.get("banner") + banner_hash = data.get("banner") + self._banner: Optional[str] = ( + f"https://cdn.discordapp.com/banners/{self.id}/{banner_hash}.png" + if banner_hash + else None + ) self.premium_tier: PremiumTier = PremiumTier(data["premium_tier"]) self.premium_subscription_count: Optional[int] = data.get( "premium_subscription_count" @@ -1357,6 +1453,74 @@ class Guild: def __repr__(self) -> str: return f"" + @property + def icon(self) -> Optional["Asset"]: + if self._icon: + from .asset import Asset + + return Asset(self._icon, self._client) + return None + + @icon.setter + def icon(self, value: Optional[Union[str, "Asset"]]) -> None: + if isinstance(value, str): + self._icon = value + elif value is None: + self._icon = None + else: + self._icon = value.url + + @property + def splash(self) -> Optional["Asset"]: + if self._splash: + from .asset import Asset + + return Asset(self._splash, self._client) + return None + + @splash.setter + def splash(self, value: Optional[Union[str, "Asset"]]) -> None: + if isinstance(value, str): + self._splash = value + elif value is None: + self._splash = None + else: + self._splash = value.url + + @property + def discovery_splash(self) -> Optional["Asset"]: + if self._discovery_splash: + from .asset import Asset + + return Asset(self._discovery_splash, self._client) + return None + + @discovery_splash.setter + def discovery_splash(self, value: Optional[Union[str, "Asset"]]) -> None: + if isinstance(value, str): + self._discovery_splash = value + elif value is None: + self._discovery_splash = None + else: + self._discovery_splash = value.url + + @property + def banner(self) -> Optional["Asset"]: + if self._banner: + from .asset import Asset + + return Asset(self._banner, self._client) + return None + + @banner.setter + def banner(self, value: Optional[Union[str, "Asset"]]) -> None: + if isinstance(value, str): + self._banner = value + elif value is None: + self._banner = None + else: + self._banner = value.url + async def fetch_widget(self) -> Dict[str, Any]: """|coro| Fetch this guild's widget settings.""" @@ -2089,7 +2253,12 @@ class Webhook: self.guild_id: Optional[str] = data.get("guild_id") self.channel_id: Optional[str] = data.get("channel_id") self.name: Optional[str] = data.get("name") - self.avatar: Optional[str] = data.get("avatar") + avatar_hash = data.get("avatar") + self._avatar: Optional[str] = ( + f"https://cdn.discordapp.com/webhooks/{self.id}/{avatar_hash}.png" + if avatar_hash + else None + ) self.token: Optional[str] = data.get("token") self.application_id: Optional[str] = data.get("application_id") self.url: Optional[str] = data.get("url") @@ -2098,6 +2267,25 @@ class Webhook: def __repr__(self) -> str: return f"" + @property + def avatar(self) -> Optional["Asset"]: + """Return the webhook's avatar as an :class:`Asset`.""" + + if self._avatar: + from .asset import Asset + + return Asset(self._avatar, self._client) + return None + + @avatar.setter + def avatar(self, value: Optional[Union[str, "Asset"]]) -> None: + if isinstance(value, str): + self._avatar = value + elif value is None: + self._avatar = None + else: + self._avatar = value.url + @classmethod def from_url( cls, url: str, session: Optional[aiohttp.ClientSession] = None diff --git a/tests/test_asset.py b/tests/test_asset.py new file mode 100644 index 0000000..5661863 --- /dev/null +++ b/tests/test_asset.py @@ -0,0 +1,14 @@ +from disagreement.models import User +from disagreement.asset import Asset + + +def test_user_avatar_returns_asset(): + user = User({"id": "1", "username": "u", "discriminator": "0001", "avatar": "abc"}) + avatar = user.avatar + assert isinstance(avatar, Asset) + assert avatar.url == "https://cdn.discordapp.com/avatars/1/abc.png" + + +def test_user_avatar_none(): + user = User({"id": "1", "username": "u", "discriminator": "0001"}) + assert user.avatar is None