Repository: hikari-py/hikari
Pull request #940 (2021-12-22 12:59:09)

Implement member timeout
### Summary
Implements member `communication_disabled_until` and the new `MODERATE_MEMBERS` permission.

### Checklist
<!-- Make sure to tick all the following boxes by putting an `x` in between (like this `[x]`) -->
- [x] I have run `nox` and all the pipelines have passed.
- [x] Dav has made unittests according to the code I have added/modified/deleted.

### Related issues
<!--
To mention an issue use `#issue-id` and to mention a merge request use `!merge-request-id`
To close/fix an issue use `Close #issue-id` or `Fix #issue-id` (depending on the merge request)
-->


davfsa: Just as a note, `Member.edit` is missing the `communication_disabled_until` argument

Patch:
diff --git a/changes/940.feature.md b/changes/940.feature.md
new file mode 100644
index 00000000..370c81bc
--- /dev/null
+++ b/changes/940.feature.md
@@ -0,0 +1,4 @@
+Implement member timeouts
+ - Add `raw_communication_disabled_until` and `communication_disabled_until` to `Member`
+ - Add `MODERATE_MEMBERS` to `Permission`
+ - Add `communication_disabled_until` attribute to `edit_member`
diff --git a/hikari/api/rest.py b/hikari/api/rest.py
index 4b71cf35..52305954 100644
--- a/hikari/api/rest.py
+++ b/hikari/api/rest.py
@@ -4953,6 +4953,7 @@ async def edit_member(
         voice_channel: undefined.UndefinedNoneOr[
             snowflakes.SnowflakeishOr[channels_.GuildVoiceChannel]
         ] = undefined.UNDEFINED,
+        communication_disabled_until: undefined.UndefinedNoneOr[datetime.datetime] = undefined.UNDEFINED,
         reason: undefined.UndefinedOr[str] = undefined.UNDEFINED,
     ) -> guilds.Member:
         """Edit a guild member.
@@ -4997,6 +4998,12 @@ async def edit_member(
             !!! note
                 If the member is not in a voice channel, this will
                 take no effect.
+        communication_disabled_until : hikari.undefined.UndefinedNoneOr[datetime.datetime]
+            If provided, the datetime when the timeout (disable communication)
+            of the member expires, up to 28 days in the future, or `builtins.None`
+            to remove the timeout from the member.
+
+            Requires the `MODERATE_MEMBERS` permission.
         reason : hikari.undefined.UndefinedOr[builtins.str]
             If provided, the reason that will be recorded in the audit logs.
             Maximum of 512 characters.
diff --git a/hikari/guilds.py b/hikari/guilds.py
index ccb74646..e0aedfa7 100644
--- a/hikari/guilds.py
+++ b/hikari/guilds.py
@@ -68,6 +68,7 @@
 from hikari.internal import attr_extensions
 from hikari.internal import enums
 from hikari.internal import routes
+from hikari.internal import time
 
 if typing.TYPE_CHECKING:
     import datetime
@@ -79,7 +80,6 @@
     from hikari import permissions as permissions_
     from hikari import presences as presences_
     from hikari import voices as voices_
-    from hikari.internal import time
 
 
 @typing.final
@@ -366,6 +366,17 @@ class Member(users.User):
     Will be `builtins.None` if the member is not a premium user.
     """
 
+    raw_communication_disabled_until: typing.Optional[datetime.datetime] = attr.field(repr=False)
+    """The datetime when this member's timeout will expire.
+
+     Will be `builtins.None` if the member is not timed out.
+
+     !!! note
+        The datetime might be in the past, so it is recommended to use
+        `communication_disabled_until` method to check if the member is timed
+        out at the time of the call.
+     """
+
     role_ids: typing.Sequence[snowflakes.Snowflake] = attr.field(repr=False)
     """A sequence of the IDs of the member's current roles."""
 
@@ -487,6 +498,23 @@ def mention(self) -> str:
         """
         return f"<@!{self.id}>" if self.nickname is not None else self.user.mention
 
+    def communication_disabled_until(self) -> typing.Optional[datetime.datetime]:
+        """Return when the timeout for this member ends.
+
+        Unlike `raw_communictation_disabled_until`, this will always be
+        `builtins.None` if the member is not currently timed out.
+
+        !!! note
+            The output of this function can depend based on when
+            the function is called.
+        """
+        if (
+            self.raw_communication_disabled_until is not None
+            and self.raw_communication_disabled_until > time.utc_datetime()
+        ):
+            return self.raw_communication_disabled_until
+        return None
+
     def get_presence(self) -> typing.Optional[presences_.MemberPresence]:
         """Get the cached presence for this member, if known.
 
@@ -879,6 +907,7 @@ async def edit(
         voice_channel: undefined.UndefinedNoneOr[
             snowflakes.SnowflakeishOr[channels_.GuildVoiceChannel]
         ] = undefined.UNDEFINED,
+        communication_disabled_until: undefined.UndefinedNoneOr[datetime.datetime] = undefined.UNDEFINED,
         reason: undefined.UndefinedOr[str] = undefined.UNDEFINED,
     ) -> Member:
         """Edit the member.
@@ -914,6 +943,12 @@ async def edit(
             !!! note
                 If the member is not in a voice channel, this will
                 take no effect.
+        communication_disabled_until : hikari.undefined.UndefinedNoneOr[datetime.datetime]
+            If provided, the datetime when the timeout (disable communication)
+            of the member expires, up to 28 days in the future, or `builtins.None`
+            to remove the timeout from the member.
+
+            Requires the `MODERATE_MEMBERS` permission.
         reason : hikari.undefined.UndefinedOr[builtins.str]
             If provided, the reason that will be recorded in the audit logs.
             Maximum of 512 characters.
@@ -955,6 +990,7 @@ async def edit(
             mute=mute,
             deaf=deaf,
             voice_channel=voice_channel,
+            communication_disabled_until=communication_disabled_until,
             reason=reason,
         )
 
diff --git a/hikari/impl/entity_factory.py b/hikari/impl/entity_factory.py
index 1aecadcf..1b2c7eb0 100644
--- a/hikari/impl/entity_factory.py
+++ b/hikari/impl/entity_factory.py
@@ -1177,6 +1177,11 @@ def deserialize_member(
             time.iso8601_datetime_string_to_datetime(raw_premium_since) if raw_premium_since is not None else None
         )
 
+        if raw_communication_disabled_until := payload.get("communication_disabled_until"):
+            communication_disabled_until = time.iso8601_datetime_string_to_datetime(raw_communication_disabled_until)
+        else:
+            communication_disabled_until = None
+
         return guild_models.Member(
             user=user,
             guild_id=guild_id,
@@ -1188,6 +1193,7 @@ def deserialize_member(
             is_deaf=payload.get("deaf", undefined.UNDEFINED),
             is_mute=payload.get("mute", undefined.UNDEFINED),
             is_pending=payload.get("pending", undefined.UNDEFINED),
+            raw_communication_disabled_until=communication_disabled_until,
         )
 
     def deserialize_role(
@@ -1790,6 +1796,11 @@ def _deserialize_interaction_member(
         if (raw_premium_since := payload.get("premium_since")) is not None:
             premium_since = time.iso8601_datetime_string_to_datetime(raw_premium_since)
 
+        if raw_disabled_until := payload.get("communication_disabled_until"):
+            disabled_until = time.iso8601_datetime_string_to_datetime(raw_disabled_until)
+        else:
+            disabled_until = None
+
         # TODO: deduplicate member unmarshalling logic
         return base_interactions.InteractionMember(
             user=user,
@@ -1803,6 +1814,7 @@ def _deserialize_interaction_member(
             is_mute=payload.get("mute", undefined.UNDEFINED),
             is_pending=payload.get("pending", undefined.UNDEFINED),
             permissions=permission_models.Permissions(int(payload["permissions"])),
+            raw_communication_disabled_until=disabled_until,
         )
 
     def deserialize_command_interaction(
diff --git a/hikari/impl/rest.py b/hikari/impl/rest.py
index 7ef058da..c8d5c802 100644
--- a/hikari/impl/rest.py
+++ b/hikari/impl/rest.py
@@ -2706,6 +2706,7 @@ async def edit_member(
         voice_channel: undefined.UndefinedNoneOr[
             snowflakes.SnowflakeishOr[channels_.GuildVoiceChannel]
         ] = undefined.UNDEFINED,
+        communication_disabled_until: undefined.UndefinedNoneOr[datetime.datetime] = undefined.UNDEFINED,
         reason: undefined.UndefinedOr[str] = undefined.UNDEFINED,
     ) -> guilds.Member:
         route = routes.PATCH_GUILD_MEMBER.compile(guild=guild, user=user)
@@ -2717,9 +2718,14 @@ async def edit_member(
 
         if voice_channel is None:
             body.put("channel_id", None)
-        elif voice_channel is not undefined.UNDEFINED:
+        else:
             body.put_snowflake("channel_id", voice_channel)
 
+        if isinstance(communication_disabled_until, datetime.datetime):
+            body.put("communication_disabled_until", communication_disabled_until.isoformat())
+        else:
+            body.put("communication_disabled_until", communication_disabled_until)
+
         response = await self._request(route, json=body, reason=reason)
         assert isinstance(response, dict)
         return self._entity_factory.deserialize_member(response, guild_id=snowflakes.Snowflake(guild))
diff --git a/hikari/internal/cache.py b/hikari/internal/cache.py
index 80db93e0..f54d04b0 100644
--- a/hikari/internal/cache.py
+++ b/hikari/internal/cache.py
@@ -412,6 +412,7 @@ class MemberData(BaseData[guilds.Member]):
     is_deaf: undefined.UndefinedOr[bool] = attr.field()
     is_mute: undefined.UndefinedOr[bool] = attr.field()
     is_pending: undefined.UndefinedOr[bool] = attr.field()
+    raw_communication_disabled_until: typing.Optional[datetime.datetime] = attr.field()
     # meta-attribute
     has_been_deleted: bool = attr.field(default=False)
 
@@ -429,6 +430,7 @@ def build_from_entity(
             is_mute=member.is_mute,
             is_pending=member.is_pending,
             user=user or RefCell(copy.copy(member.user)),
+            raw_communication_disabled_until=member.raw_communication_disabled_until,
             # role_ids is a special case as it may be mutable so we want to ensure it's immutable when cached.
             role_ids=tuple(member.role_ids),
         )
@@ -444,6 +446,7 @@ def build_entity(self, _: traits.RESTAware, /) -> guilds.Member:
             is_deaf=self.is_deaf,
             is_mute=self.is_mute,
             is_pending=self.is_pending,
+            raw_communication_disabled_until=self.raw_communication_disabled_until,
             user=self.user.copy(),
         )
 
diff --git a/hikari/permissions.py b/hikari/permissions.py
index 5e94e924..f93905fb 100644
--- a/hikari/permissions.py
+++ b/hikari/permissions.py
@@ -277,7 +277,10 @@ class Permissions(enums.Flag):
     """Allows for sending messages in threads."""
 
     START_EMBEDDED_ACTIVITIES = 1 << 39
-    """Allows for launching activities (applications with the `EMBEDDED` flag) in a voice channel"""
+    """Allows for launching activities (applications with the `EMBEDDED` flag) in a voice channel."""
+
+    MODERATE_MEMBERS = 1 << 40
+    """Allows for timing out members."""
 
     @classmethod
     def all_permissions(cls) -> Permissions:


Test patch:
diff --git a/tests/hikari/impl/test_cache.py b/tests/hikari/impl/test_cache.py
index 715802ed..18411bc6 100644
--- a/tests/hikari/impl/test_cache.py
+++ b/tests/hikari/impl/test_cache.py
@@ -1502,6 +1502,9 @@ def test__build_member(self, cache_impl):
             is_deaf=False,
             is_mute=True,
             is_pending=False,
+            raw_communication_disabled_until=datetime.datetime(
+                2021, 10, 18, 13, 11, 18, 384554, tzinfo=datetime.timezone.utc
+            ),
         )
 
         member = cache_impl._build_member(cache_utilities.RefCell(member_data))
@@ -1517,6 +1520,9 @@ def test__build_member(self, cache_impl):
         assert member.is_deaf is False
         assert member.is_mute is True
         assert member.is_pending is False
+        assert member.raw_communication_disabled_until == datetime.datetime(
+            2021, 10, 18, 13, 11, 18, 384554, tzinfo=datetime.timezone.utc
+        )
 
     def test_clear_members(self, cache_impl):
         mock_user_1 = cache_utilities.RefCell(mock.Mock(id=snowflakes.Snowflake(2123123)))
@@ -1850,6 +1856,9 @@ def test_set_member(self, cache_impl):
             guild_avatar_hash="gay",
             is_mute=False,
             is_pending=True,
+            raw_communication_disabled_until=datetime.datetime(
+                2021, 10, 18, 13, 11, 18, 384554, tzinfo=datetime.timezone.utc
+            ),
         )
         cache_impl._set_user = mock.Mock(return_value=mock_user_ref)
         cache_impl._increment_ref_count = mock.Mock()
@@ -1879,6 +1888,9 @@ def test_set_member(self, cache_impl):
         assert member_entry.object.is_deaf is True
         assert member_entry.object.is_mute is False
         assert member_entry.object.is_pending is True
+        assert member_entry.object.raw_communication_disabled_until == datetime.datetime(
+            2021, 10, 18, 13, 11, 18, 384554, tzinfo=datetime.timezone.utc
+        )
 
     def test_set_member_doesnt_increment_user_ref_count_for_pre_cached_member(self, cache_impl):
         mock_user = mock.Mock(users.User, id=snowflakes.Snowflake(645234123))
diff --git a/tests/hikari/impl/test_entity_factory.py b/tests/hikari/impl/test_entity_factory.py
index 7a78fb2c..aa77e289 100644
--- a/tests/hikari/impl/test_entity_factory.py
+++ b/tests/hikari/impl/test_entity_factory.py
@@ -1886,6 +1886,7 @@ def member_payload(self, user_payload):
             "mute": True,
             "pending": False,
             "user": user_payload,
+            "communication_disabled_until": "2021-10-18T06:26:56.936000+00:00",
         }
 
     def test_deserialize_member(self, entity_factory_impl, mock_app, member_payload, user_payload):
@@ -1899,6 +1900,9 @@ def test_deserialize_member(self, entity_factory_impl, mock_app, member_payload,
         assert member.role_ids == [11111, 22222, 33333, 44444, 76543325]
         assert member.joined_at == datetime.datetime(2015, 4, 26, 6, 26, 56, 936000, tzinfo=datetime.timezone.utc)
         assert member.premium_since == datetime.datetime(2019, 5, 17, 6, 26, 56, 936000, tzinfo=datetime.timezone.utc)
+        assert member.raw_communication_disabled_until == datetime.datetime(
+            2021, 10, 18, 6, 26, 56, 936000, tzinfo=datetime.timezone.utc
+        )
         assert member.is_deaf is False
         assert member.is_mute is True
         assert member.is_pending is False
@@ -1936,6 +1940,7 @@ def test_deserialize_member_with_null_fields(self, entity_factory_impl, user_pay
                 "pending": False,
                 "user": user_payload,
                 "guild_id": "123123453234",
+                "communication_disabled_until": None,
             }
         )
         assert member.nickname is None
@@ -2883,6 +2888,7 @@ def interaction_member_payload(self, user_payload):
             "avatar": "oestrogen",
             "permissions": "17179869183",
             "premium_since": "2020-10-01T23:06:10.431000+00:00",
+            "communication_disabled_until": "2021-10-18T23:06:10.431000+00:00",
             "roles": [
                 "582345963851743243",
                 "582689893965365248",
@@ -2902,6 +2908,9 @@ def test__deserialize_interaction_member(self, entity_factory_impl, interaction_
         assert member.is_mute is undefined.UNDEFINED
         assert member.is_pending is False
         assert member.premium_since == datetime.datetime(2020, 10, 1, 23, 6, 10, 431000, tzinfo=datetime.timezone.utc)
+        assert member.raw_communication_disabled_until == datetime.datetime(
+            2021, 10, 18, 23, 6, 10, 431000, tzinfo=datetime.timezone.utc
+        )
         assert member.role_ids == [
             582345963851743243,
             582689893965365248,
@@ -2938,11 +2947,13 @@ def test__deserialize_interaction_member_with_unset_fields(
     ):
         del interaction_member_payload["premium_since"]
         del interaction_member_payload["avatar"]
+        del interaction_member_payload["communication_disabled_until"]
 
         member = entity_factory_impl._deserialize_interaction_member(interaction_member_payload, guild_id=43123123)
 
         assert member.guild_avatar_hash is None
         assert member.premium_since is None
+        assert member.raw_communication_disabled_until is None
 
     def test__deserialize_interaction_member_with_passed_user(
         self, entity_factory_impl, interaction_member_payload, user_payload
diff --git a/tests/hikari/impl/test_rest.py b/tests/hikari/impl/test_rest.py
index e84d8e5d..421d7d90 100644
--- a/tests/hikari/impl/test_rest.py
+++ b/tests/hikari/impl/test_rest.py
@@ -3452,8 +3452,16 @@ async def test_search_members(self, rest_client):
 
     async def test_edit_member(self, rest_client):
         expected_route = routes.PATCH_GUILD_MEMBER.compile(guild=123, user=456)
-        expected_json = {"nick": "test", "roles": ["654", "321"], "mute": True, "deaf": False, "channel_id": "987"}
+        expected_json = {
+            "nick": "test",
+            "roles": ["654", "321"],
+            "mute": True,
+            "deaf": False,
+            "channel_id": "987",
+            "communication_disabled_until": "2021-10-18T07:18:11.554023+00:00",
+        }
         rest_client._request = mock.AsyncMock(return_value={"id": "789"})
+        mock_timestamp = datetime.datetime(2021, 10, 18, 7, 18, 11, 554023, tzinfo=datetime.timezone.utc)
 
         result = await rest_client.edit_member(
             StubModel(123),
@@ -3463,6 +3471,7 @@ async def test_edit_member(self, rest_client):
             mute=True,
             deaf=False,
             voice_channel=StubModel(987),
+            communication_disabled_until=mock_timestamp,
             reason="because i can",
         )
         assert result is rest_client._entity_factory.deserialize_member.return_value
@@ -3492,11 +3501,25 @@ async def test_edit_member_when_voice_channel_is_None(self, rest_client):
         rest_client._entity_factory.deserialize_member.assert_called_once_with(
             rest_client._request.return_value, guild_id=123
         )
-        rest_client._request.assert_awaited_once_with(
-            expected_route,
-            json=expected_json,
+        rest_client._request.assert_awaited_once_with(expected_route, json=expected_json, reason="because i can")
+
+    async def test_edit_member_when_communication_disabled_until_is_None(self, rest_client):
+        expected_route = routes.PATCH_GUILD_MEMBER.compile(guild=123, user=456)
+        expected_json = {"communication_disabled_until": None}
+        rest_client._request = mock.AsyncMock(return_value={"id": "789"})
+
+        result = await rest_client.edit_member(
+            StubModel(123),
+            StubModel(456),
+            communication_disabled_until=None,
             reason="because i can",
         )
+        assert result is rest_client._entity_factory.deserialize_member.return_value
+
+        rest_client._entity_factory.deserialize_member.assert_called_once_with(
+            rest_client._request.return_value, guild_id=123
+        )
+        rest_client._request.assert_awaited_once_with(expected_route, json=expected_json, reason="because i can")
 
     async def test_edit_member_without_optionals(self, rest_client):
         expected_route = routes.PATCH_GUILD_MEMBER.compile(guild=123, user=456)
diff --git a/tests/hikari/integration/test_users_comparison.py b/tests/hikari/integration/test_users_comparison.py
index a7d0faa8..a2f28f98 100644
--- a/tests/hikari/integration/test_users_comparison.py
+++ b/tests/hikari/integration/test_users_comparison.py
@@ -81,6 +81,7 @@ def make_guild_member(user_id, username):
         is_deaf=False,
         is_mute=False,
         is_pending=False,
+        raw_communication_disabled_until=None,
     )
 
 
diff --git a/tests/hikari/test_guilds.py b/tests/hikari/test_guilds.py
index 02ae7566..927832fd 100644
--- a/tests/hikari/test_guilds.py
+++ b/tests/hikari/test_guilds.py
@@ -33,6 +33,7 @@
 from hikari import urls
 from hikari.impl import bot
 from hikari.internal import routes
+from hikari.internal import time
 from tests.hikari import hikari_test_helpers
 
 
@@ -227,6 +228,7 @@ def model(self, mock_user):
                 snowflakes.Snowflake(1234),
             ],
             user=mock_user,
+            raw_communication_disabled_until=None,
         )
 
     def test_str_operator(self, model, mock_user):
@@ -272,6 +274,24 @@ def test_guild_avatar_url_property(self, model):
         with mock.patch.object(guilds.Member, "make_guild_avatar_url") as make_guild_avatar_url:
             assert model.guild_avatar_url is make_guild_avatar_url.return_value
 
+    def test_communication_disabled_until(self, model):
+        model.raw_communication_disabled_until = datetime.datetime(2021, 11, 22)
+
+        with mock.patch.object(time, "utc_datetime", return_value=datetime.datetime(2021, 10, 18)):
+            assert model.communication_disabled_until() == datetime.datetime(2021, 11, 22)
+
+    def test_communication_disabled_until_when_raw_communication_disabled_until_is_None(self, model):
+        model.raw_communication_disabled_until = None
+
+        with mock.patch.object(time, "utc_datetime", return_value=datetime.datetime(2021, 10, 18)):
+            assert model.communication_disabled_until() is None
+
+    def test_comminucation_disabled_until_when_raw_communication_disabled_until_is_in_the_past(self, model):
+        model.raw_communication_disabled_until = datetime.datetime(2021, 10, 18)
+
+        with mock.patch.object(time, "utc_datetime", return_value=datetime.datetime(2021, 11, 22)):
+            assert model.communication_disabled_until() is None
+
     def test_make_avatar_url(self, model, mock_user):
         result = model.make_avatar_url(ext="png", size=4096)
         mock_user.make_avatar_url.assert_called_once_with(ext="png", size=4096)
@@ -397,8 +417,15 @@ async def test_remove_role(self, model):
     @pytest.mark.asyncio()
     async def test_edit(self, model):
         model.app.rest.edit_member = mock.AsyncMock()
+        disabled_until = datetime.datetime(2021, 11, 17)
         edit = await model.edit(
-            nick="Imposter", roles=[123, 432, 345], mute=False, deaf=True, voice_channel=4321245, reason="I'm God"
+            nick="Imposter",
+            roles=[123, 432, 345],
+            mute=False,
+            deaf=True,
+            voice_channel=4321245,
+            communication_disabled_until=disabled_until,
+            reason="I'm God",
         )
 
         model.app.rest.edit_member.assert_awaited_once_with(
@@ -409,6 +436,7 @@ async def test_edit(self, model):
             mute=False,
             deaf=True,
             voice_channel=4321245,
+            communication_disabled_until=disabled_until,
             reason="I'm God",
         )
 
diff --git a/tests/hikari/test_permissions.py b/tests/hikari/test_permissions.py
index ca33a84d..ffd6f7d7 100644
--- a/tests/hikari/test_permissions.py
+++ b/tests/hikari/test_permissions.py
@@ -27,4 +27,4 @@ def test_all_permissions(self):
         all_perms = permissions.Permissions.all_permissions()
 
         assert isinstance(all_perms, permissions.Permissions)
-        assert all_perms == 1090921693183
+        assert all_perms == 2190433320959