Skip to content

Commit

Permalink
Role Icons (#838)
Browse files Browse the repository at this point in the history
* Add ability to create, get and edit roles with icons.

* Document role icons to CHANGELOG.md

* Apply suggestions from code review for role icons

Co-authored-by: davfsa <[email protected]>

* Add more tests for role icons

* Apply more suggestions from code review for role icons

Co-authored-by: davfsa <[email protected]>

* Apply suggestions

Co-authored-by: davfsa <[email protected]>

* Add tests for icons and unicode_emoji fail when used at the same time.

* Improve documentation, add missing tests and remove dead code

Co-authored-by: davfsa <[email protected]>
  • Loading branch information
vivekjoshy and davfsa authored Oct 12, 2021
1 parent ce8a82c commit 3f71783
Show file tree
Hide file tree
Showing 9 changed files with 158 additions and 12 deletions.
1 change: 1 addition & 0 deletions changes/838.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add role icons
19 changes: 17 additions & 2 deletions hikari/api/rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -5531,6 +5531,8 @@ async def create_role(
color: undefined.UndefinedOr[colors.Colorish] = undefined.UNDEFINED,
colour: undefined.UndefinedOr[colors.Colorish] = undefined.UNDEFINED,
hoist: undefined.UndefinedOr[bool] = undefined.UNDEFINED,
icon: undefined.UndefinedOr[files.Resourceish] = undefined.UNDEFINED,
unicode_emoji: undefined.UndefinedOr[str] = undefined.UNDEFINED,
mentionable: undefined.UndefinedOr[bool] = undefined.UNDEFINED,
reason: undefined.UndefinedOr[str] = undefined.UNDEFINED,
) -> guilds.Role:
Expand All @@ -5557,6 +5559,10 @@ async def create_role(
An alias for `color`.
hoist : hikari.undefined.UndefinedOr[builtins.bool]
If provided, whether to hoist the role.
icon : hikari.undefined.UndefinedOr[hikari.files.Resourceish]
If provided, the role icon. Must be a 64x64 image under 256kb.
unicode_emoji : hikari.undefined.UndefinedOr[builtins.str]
If provided, the standard emoji to set as the role icon.
mentionable : hikari.undefined.UndefinedOr[builtins.bool]
If provided, whether to make the role mentionable.
reason : hikari.undefined.UndefinedOr[builtins.str]
Expand All @@ -5571,7 +5577,8 @@ async def create_role(
Raises
------
builtins.TypeError
If both `color` and `colour` are specified.
If both `color` and `colour` are specified or if both `icon` and
`unicode_emoji` are specified.
hikari.errors.BadRequestError
If any of the fields that are passed have an invalid value.
hikari.errors.ForbiddenError
Expand Down Expand Up @@ -5645,6 +5652,8 @@ async def edit_role(
color: undefined.UndefinedOr[colors.Colorish] = undefined.UNDEFINED,
colour: undefined.UndefinedOr[colors.Colorish] = undefined.UNDEFINED,
hoist: undefined.UndefinedOr[bool] = undefined.UNDEFINED,
icon: undefined.UndefinedNoneOr[files.Resourceish] = undefined.UNDEFINED,
unicode_emoji: undefined.UndefinedNoneOr[str] = undefined.UNDEFINED,
mentionable: undefined.UndefinedOr[bool] = undefined.UNDEFINED,
reason: undefined.UndefinedOr[str] = undefined.UNDEFINED,
) -> guilds.Role:
Expand All @@ -5671,6 +5680,11 @@ async def edit_role(
An alias for `color`.
hoist : hikari.undefined.UndefinedOr[builtins.bool]
If provided, whether to hoist the role.
icon : hikari.undefined.UndefinedNoneOr[hikari.files.Resourceish]
If provided, the new role icon. Must be a 64x64 image
under 256kb.
unicode_emoji : hikari.undefined.UndefinedNoneOr[builtins.str]
If provided, the new unicode emoji to set as the role icon.
mentionable : hikari.undefined.UndefinedOr[builtins.bool]
If provided, whether to make the role mentionable.
reason : hikari.undefined.UndefinedOr[builtins.str]
Expand All @@ -5685,7 +5699,8 @@ async def edit_role(
Raises
------
builtins.TypeError
If both `color` and `colour` are specified.
If both `color` and `colour` are specified or if both `icon` and
`unicode_emoji` are specified.
hikari.errors.BadRequestError
If any of the fields that are passed have an invalid value.
hikari.errors.ForbiddenError
Expand Down
52 changes: 52 additions & 0 deletions hikari/guilds.py
Original file line number Diff line number Diff line change
Expand Up @@ -1018,6 +1018,12 @@ class Role(PartialRole):
members will be hoisted under their highest role where this is set to `builtins.True`.
"""

icon_hash: typing.Optional[str] = attr.field(eq=False, hash=False, repr=False)
"""Hash of the role's icon if set, else `builtins.None`."""

unicode_emoji: typing.Optional[emojis_.UnicodeEmoji] = attr.field(eq=False, hash=False, repr=False)
"""Role's icon as an unicode emoji if set, else `builtins.None`."""

is_managed: bool = attr.field(eq=False, hash=False, repr=False)
"""Whether this role is managed by an integration."""

Expand Down Expand Up @@ -1057,6 +1063,52 @@ def colour(self) -> colours.Colour:
"""Alias for the `color` field."""
return self.color

@property
def icon_url(self) -> typing.Optional[files.URL]:
"""Role icon URL, if there is one.
Returns
-------
typing.Optional[hikari.files.URL]
The URL, or `builtins.None` if no icon exists.
"""
return self.make_icon_url()

def make_icon_url(self, *, ext: str = "png", size: int = 4096) -> typing.Optional[files.URL]:
"""Generate the icon URL for this role, if set.
If no role icon is set, this returns `builtins.None`.
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 to the icon, or `builtins.None` if not present.
Raises
------
builtins.ValueError
If `size` is not a power of two or not between 16 and 4096.
"""
if self.icon_hash is None:
return None

return routes.CDN_ROLE_ICON.compile_to_file(
urls.CDN_URL,
role_id=self.id,
hash=self.icon_hash,
size=size,
file_format=ext,
)


@typing.final
class IntegrationType(str, enums.Enum):
Expand Down
6 changes: 6 additions & 0 deletions hikari/impl/entity_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -1207,13 +1207,19 @@ def deserialize_role(
if "premium_subscriber" in tags_payload:
is_premium_subscriber_role = True

emoji: typing.Optional[emoji_models.UnicodeEmoji] = None
if (raw_emoji := payload.get("unicode_emoji")) is not None:
emoji = emoji_models.UnicodeEmoji(raw_emoji)

return guild_models.Role(
app=self._app,
id=snowflakes.Snowflake(payload["id"]),
guild_id=guild_id,
name=payload["name"],
color=color_models.Color(payload["color"]),
is_hoisted=payload["hoist"],
icon_hash=payload.get("icon"),
unicode_emoji=emoji,
position=int(payload["position"]),
permissions=permission_models.Permissions(int(payload["permissions"])),
is_managed=payload["managed"],
Expand Down
24 changes: 24 additions & 0 deletions hikari/impl/rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2853,21 +2853,32 @@ async def create_role(
color: undefined.UndefinedOr[colors.Colorish] = undefined.UNDEFINED,
colour: undefined.UndefinedOr[colors.Colorish] = undefined.UNDEFINED,
hoist: undefined.UndefinedOr[bool] = undefined.UNDEFINED,
icon: undefined.UndefinedOr[files.Resourceish] = undefined.UNDEFINED,
unicode_emoji: undefined.UndefinedOr[str] = undefined.UNDEFINED,
mentionable: undefined.UndefinedOr[bool] = undefined.UNDEFINED,
reason: undefined.UndefinedOr[str] = undefined.UNDEFINED,
) -> guilds.Role:
if not undefined.any_undefined(color, colour):
raise TypeError("Can not specify 'color' and 'colour' together.")

if not undefined.any_undefined(icon, unicode_emoji):
raise TypeError("Can not specify 'icon' and 'unicode_emoji' together.")

route = routes.POST_GUILD_ROLES.compile(guild=guild)
body = data_binding.JSONObjectBuilder()
body.put("name", name)
body.put("permissions", permissions)
body.put("color", color, conversion=colors.Color.of)
body.put("color", colour, conversion=colors.Color.of)
body.put("hoist", hoist)
body.put("unicode_emoji", unicode_emoji)
body.put("mentionable", mentionable)

if icon is not undefined.UNDEFINED:
icon_resource = files.ensure_resource(icon)
async with icon_resource.stream(executor=self._executor) as stream:
body.put("icon", await stream.data_uri())

response = await self._request(route, json=body, reason=reason)
assert isinstance(response, dict)
return self._entity_factory.deserialize_role(response, guild_id=snowflakes.Snowflake(guild))
Expand All @@ -2891,12 +2902,17 @@ async def edit_role(
color: undefined.UndefinedOr[colors.Colorish] = undefined.UNDEFINED,
colour: undefined.UndefinedOr[colors.Colorish] = undefined.UNDEFINED,
hoist: undefined.UndefinedOr[bool] = undefined.UNDEFINED,
icon: undefined.UndefinedNoneOr[files.Resourceish] = undefined.UNDEFINED,
unicode_emoji: undefined.UndefinedNoneOr[str] = undefined.UNDEFINED,
mentionable: undefined.UndefinedOr[bool] = undefined.UNDEFINED,
reason: undefined.UndefinedOr[str] = undefined.UNDEFINED,
) -> guilds.Role:
if not undefined.any_undefined(color, colour):
raise TypeError("Can not specify 'color' and 'colour' together.")

if not undefined.any_undefined(icon, unicode_emoji):
raise TypeError("Can not specify 'icon' and 'unicode_emoji' together.")

route = routes.PATCH_GUILD_ROLE.compile(guild=guild, role=role)

body = data_binding.JSONObjectBuilder()
Expand All @@ -2905,8 +2921,16 @@ async def edit_role(
body.put("color", color, conversion=colors.Color.of)
body.put("color", colour, conversion=colors.Color.of)
body.put("hoist", hoist)
body.put("unicode_emoji", unicode_emoji)
body.put("mentionable", mentionable)

if icon is None:
body.put("icon", None)
elif icon is not undefined.UNDEFINED:
icon_resource = files.ensure_resource(icon)
async with icon_resource.stream(executor=self._executor) as stream:
body.put("icon", await stream.data_uri())

response = await self._request(route, json=body, reason=reason)
assert isinstance(response, dict)
return self._entity_factory.deserialize_role(response, guild_id=snowflakes.Snowflake(guild))
Expand Down
1 change: 1 addition & 0 deletions hikari/internal/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,7 @@ def compile_to_file(
CDN_MEMBER_AVATAR: typing.Final[CDNRoute] = CDNRoute(
"/guilds/{guild_id}/users/{user_id}/avatars/{hash}", {PNG, *JPEG_JPG, WEBP, GIF}
)
CDN_ROLE_ICON: typing.Final[CDNRoute] = CDNRoute("/role-icons/{role_id}/{hash}.png", {PNG, *JPEG_JPG, WEBP})

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})
Expand Down
7 changes: 7 additions & 0 deletions tests/hikari/impl/test_entity_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -1979,6 +1979,8 @@ def guild_role_payload(self):
"name": "WE DEM BOYZZ!!!!!!",
"color": 3_447_003,
"hoist": True,
"unicode_emoji": "\N{OK HAND SIGN}",
"icon": "abc123hash",
"position": 0,
"permissions": "66321471",
"managed": False,
Expand All @@ -1996,6 +1998,9 @@ def test_deserialize_role(self, entity_factory_impl, mock_app, guild_role_payloa
assert guild_role.id == 41771983423143936
assert guild_role.guild_id == 76534453
assert guild_role.name == "WE DEM BOYZZ!!!!!!"
assert guild_role.icon_hash == "abc123hash"
assert guild_role.unicode_emoji == emoji_models.UnicodeEmoji("\N{OK HAND SIGN}")
assert isinstance(guild_role.unicode_emoji, emoji_models.UnicodeEmoji)
assert guild_role.color == color_models.Color(3_447_003)
assert guild_role.is_hoisted is True
assert guild_role.position == 0
Expand All @@ -2009,10 +2014,12 @@ def test_deserialize_role(self, entity_factory_impl, mock_app, guild_role_payloa

def test_deserialize_role_with_missing_or_unset_fields(self, entity_factory_impl, guild_role_payload):
guild_role_payload["tags"] = {}
guild_role_payload["unicode_emoji"] = None
guild_role = entity_factory_impl.deserialize_role(guild_role_payload, guild_id=snowflakes.Snowflake(76534453))
assert guild_role.bot_id is None
assert guild_role.integration_id is None
assert guild_role.is_premium_subscriber_role is False
assert guild_role.unicode_emoji is None

def test_deserialize_role_with_no_tags(self, entity_factory_impl, guild_role_payload):
del guild_role_payload["tags"]
Expand Down
32 changes: 22 additions & 10 deletions tests/hikari/impl/test_rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -1724,6 +1724,8 @@ async def test_edit_permission_overwrites(self, rest_client):
color=None,
guild_id=123,
is_hoisted=True,
icon_hash="icon_hash",
unicode_emoji=None,
is_managed=False,
name="",
is_mentionable=True,
Expand Down Expand Up @@ -3603,29 +3605,29 @@ async def test_fetch_roles(self, rest_client):
[mock.call({"id": "456"}, guild_id=123), mock.call({"id": "789"}, guild_id=123)]
)

async def test_create_role(self, rest_client):
role = StubModel(456)
async def test_create_role(self, rest_client, file_resource_patch):
expected_route = routes.POST_GUILD_ROLES.compile(guild=123)
expected_json = {
"name": "admin",
"permissions": 8,
"color": colors.Color.from_int(12345),
"hoist": True,
"icon": "some data",
"mentionable": False,
}
rest_client._request = mock.AsyncMock(return_value={"id": "456"})
rest_client._entity_factory.deserialize_role = mock.Mock(return_value=role)

returned = await rest_client.create_role(
StubModel(123),
name="admin",
permissions=permissions.Permissions.ADMINISTRATOR,
color=colors.Color.from_int(12345),
hoist=True,
icon="icon.png",
mentionable=False,
reason="roles are cool",
)
assert returned is role
assert returned is rest_client._entity_factory.deserialize_role.return_value

rest_client._request.assert_awaited_once_with(expected_route, json=expected_json, reason="roles are cool")
rest_client._entity_factory.deserialize_role.assert_called_once_with({"id": "456"}, guild_id=123)
Expand Down Expand Up @@ -3657,11 +3659,15 @@ async def test_create_role_when_permissions_undefined(self, rest_client):
rest_client._entity_factory.deserialize_role.assert_called_once_with({"id": "456"}, guild_id=123)

async def test_create_role_when_color_and_colour_specified(self, rest_client):
with pytest.raises(TypeError):
with pytest.raises(TypeError, match=r"Can not specify 'color' and 'colour' together."):
await rest_client.create_role(
StubModel(123), color=colors.Color.from_int(12345), colour=colors.Color.from_int(12345)
)

async def test_create_role_when_icon_unicode_emoji_specified(self, rest_client):
with pytest.raises(TypeError, match=r"Can not specify 'icon' and 'unicode_emoji' together."):
await rest_client.create_role(StubModel(123), icon="icon.png", unicode_emoji="\N{OK HAND SIGN}")

async def test_reposition_roles(self, rest_client):
expected_route = routes.POST_GUILD_ROLES.compile(guild=123)
expected_json = [{"id": "456", "position": 1}, {"id": "789", "position": 2}]
Expand All @@ -3671,18 +3677,17 @@ async def test_reposition_roles(self, rest_client):

rest_client._request.assert_awaited_once_with(expected_route, json=expected_json)

async def test_edit_role(self, rest_client):
role = StubModel(456)
async def test_edit_role(self, rest_client, file_resource_patch):
expected_route = routes.PATCH_GUILD_ROLE.compile(guild=123, role=789)
expected_json = {
"name": "admin",
"permissions": 8,
"color": colors.Color.from_int(12345),
"hoist": True,
"icon": "some data",
"mentionable": False,
}
rest_client._request = mock.AsyncMock(return_value={"id": "456"})
rest_client._entity_factory.deserialize_role = mock.Mock(return_value=role)

returned = await rest_client.edit_role(
StubModel(123),
Expand All @@ -3691,20 +3696,27 @@ async def test_edit_role(self, rest_client):
permissions=permissions.Permissions.ADMINISTRATOR,
color=colors.Color.from_int(12345),
hoist=True,
icon="icon.png",
mentionable=False,
reason="roles are cool",
)
assert returned is role
assert returned is rest_client._entity_factory.deserialize_role.return_value

rest_client._request.assert_awaited_once_with(expected_route, json=expected_json, reason="roles are cool")
rest_client._entity_factory.deserialize_role.assert_called_once_with({"id": "456"}, guild_id=123)

async def test_edit_role_when_color_and_colour_specified(self, rest_client):
with pytest.raises(TypeError):
with pytest.raises(TypeError, match=r"Can not specify 'color' and 'colour' together."):
await rest_client.edit_role(
StubModel(123), StubModel(456), color=colors.Color.from_int(12345), colour=colors.Color.from_int(12345)
)

async def test_edit_role_when_icon_and_unicode_emoji_specified(self, rest_client):
with pytest.raises(TypeError, match=r"Can not specify 'icon' and 'unicode_emoji' together."):
await rest_client.edit_role(
StubModel(123), StubModel(456), icon="icon.png", unicode_emoji="\N{OK HAND SIGN}"
)

async def test_delete_role(self, rest_client):
expected_route = routes.DELETE_GUILD_ROLE.compile(guild=123, role=456)
rest_client._request = mock.AsyncMock()
Expand Down
Loading

0 comments on commit 3f71783

Please sign in to comment.