Add Asset model and avatar helpers (#119)

This commit is contained in:
Slipstream 2025-06-15 20:39:14 -06:00 committed by GitHub
parent aa55aa1d4c
commit 4b3b6aeb45
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 292 additions and 27 deletions

View File

@ -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

View File

@ -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",

51
disagreement/asset.py Normal file
View File

@ -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"<Asset url='{self.url}'>"

View File

@ -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)

View File

@ -2,6 +2,8 @@
Data models for Discord objects.
"""
from __future__ import annotations
import asyncio
import datetime
import io
@ -54,6 +56,7 @@ if TYPE_CHECKING:
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"<User id='{self.id}' username='{username}' discriminator='{disc}'>"
@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"<Role id='{self.id}' name='{self.name}'>"
@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"<Member id='{self.id}' username='{self.username}' nick='{self.nick}'>"
@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,6 +1023,7 @@ class Member(User): # Member inherits from User
return base
@property
def voice(self) -> Optional["VoiceState"]:
"""Return the member's cached voice state as a :class:`VoiceState`."""
@ -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"<Guild id='{self.id}' name='{self.name}'>"
@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"<Webhook id='{self.id}' name='{self.name}'>"
@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

14
tests/test_asset.py Normal file
View File

@ -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