Repository: hikari-py/hikari
Pull request #1382 (2022-12-01 23:42:32)

Add new nsfw field for commands
### 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] I have made unittests according to the code I have added/modified/deleted.



Patch:
diff --git a/changes/1382.feature.md b/changes/1382.feature.md
new file mode 100644
index 00000000..69da9200
--- /dev/null
+++ b/changes/1382.feature.md
@@ -0,0 +1,1 @@
+Add functionality to create and deserialize age-restricted (NSFW) commands.
diff --git a/hikari/api/rest.py b/hikari/api/rest.py
index fea1d4fb..22a0ffbc 100644
--- a/hikari/api/rest.py
+++ b/hikari/api/rest.py
@@ -7450,6 +7450,7 @@ async def create_slash_command(
             undefined.UndefinedType, int, permissions_.Permissions
         ] = undefined.UNDEFINED,
         dm_enabled: undefined.UndefinedOr[bool] = undefined.UNDEFINED,
+        nsfw: undefined.UndefinedOr[bool] = undefined.UNDEFINED,
     ) -> commands.SlashCommand:
         r"""Create an application command.
 
@@ -7485,6 +7486,8 @@ async def create_slash_command(
             Whether this command is enabled in DMs with the bot.
 
             This can only be applied to non-guild commands.
+        nsfw : hikari.undefined.UndefinedOr[builtins.bool]
+            Whether this command should be age-restricted.
 
         Returns
         -------
@@ -7531,6 +7534,7 @@ async def create_context_menu_command(
             undefined.UndefinedType, int, permissions_.Permissions
         ] = undefined.UNDEFINED,
         dm_enabled: undefined.UndefinedOr[bool] = undefined.UNDEFINED,
+        nsfw: undefined.UndefinedOr[bool] = undefined.UNDEFINED,
     ) -> commands.ContextMenuCommand:
         r"""Create an application command.
 
@@ -7563,6 +7567,8 @@ async def create_context_menu_command(
             Whether this command is enabled in DMs with the bot.
 
             This can only be applied to non-guild commands.
+        nsfw : hikari.undefined.UndefinedOr[builtins.bool]
+            Whether this command should be age-restricted.
 
         Returns
         -------
diff --git a/hikari/api/special_endpoints.py b/hikari/api/special_endpoints.py
index 169cf7a8..64cc6fae 100644
--- a/hikari/api/special_endpoints.py
+++ b/hikari/api/special_endpoints.py
@@ -1006,6 +1006,11 @@ def is_dm_enabled(self) -> undefined.UndefinedOr[bool]:
         Only applicable to globally-scoped commands.
         """
 
+    @property
+    @abc.abstractmethod
+    def is_nsfw(self) -> undefined.UndefinedOr[bool]:
+        """Whether this command age-restricted."""
+
     @property
     @abc.abstractmethod
     def name_localizations(self) -> typing.Mapping[typing.Union[locales.Locale, str], str]:
@@ -1061,6 +1066,21 @@ def set_is_dm_enabled(self: _T, state: undefined.UndefinedOr[bool], /) -> _T:
             Object of this command builder for chained calls.
         """
 
+    @abc.abstractmethod
+    def set_is_nsfw(self: _T, state: undefined.UndefinedOr[bool], /) -> _T:
+        """Set whether this command will be age-restricted.
+
+        Parameters
+        ----------
+        state : hikari.undefined.UndefinedOr[builtins.bool]
+            Whether this command is age-restricted.
+
+        Returns
+        -------
+        CommandBuilder
+            Object of this command builder for chained calls.
+        """
+
     @abc.abstractmethod
     def set_name_localizations(
         self: _T, name_localizations: typing.Mapping[typing.Union[locales.Locale, str], str], /
diff --git a/hikari/commands.py b/hikari/commands.py
index 2684a470..5b6abcef 100644
--- a/hikari/commands.py
+++ b/hikari/commands.py
@@ -237,6 +237,9 @@ class PartialCommand(snowflakes.Unique):
     is_dm_enabled: bool = attr.field(eq=False, hash=False, repr=True)
     """Whether this command is enabled in DMs with the bot."""
 
+    is_nsfw: bool = attr.field(eq=False, hash=False, repr=True)
+    """Whether this command is age-restricted."""
+
     guild_id: typing.Optional[snowflakes.Snowflake] = attr.field(eq=False, hash=False, repr=False)
     """ID of the guild this command is in.
 
diff --git a/hikari/impl/entity_factory.py b/hikari/impl/entity_factory.py
index d93d04ef..e4c309ab 100644
--- a/hikari/impl/entity_factory.py
+++ b/hikari/impl/entity_factory.py
@@ -2093,6 +2093,7 @@ def deserialize_slash_command(
             options=options,
             default_member_permissions=default_member_permissions,
             is_dm_enabled=payload.get("dm_permission", True),
+            is_nsfw=payload.get("nsfw", False),
             guild_id=guild_id,
             version=snowflakes.Snowflake(payload["version"]),
             name_localizations=name_localizations,
@@ -2131,6 +2132,7 @@ def deserialize_context_menu_command(
             name=payload["name"],
             default_member_permissions=default_member_permissions,
             is_dm_enabled=payload.get("dm_permission", True),
+            is_nsfw=payload.get("nsfw", False),
             guild_id=guild_id,
             version=snowflakes.Snowflake(payload["version"]),
             name_localizations=name_localizations,
diff --git a/hikari/impl/rest.py b/hikari/impl/rest.py
index 2c14f712..059e1b11 100644
--- a/hikari/impl/rest.py
+++ b/hikari/impl/rest.py
@@ -3425,6 +3425,7 @@ async def _create_application_command(
             undefined.UndefinedType, int, permissions_.Permissions
         ] = undefined.UNDEFINED,
         dm_enabled: undefined.UndefinedOr[bool] = undefined.UNDEFINED,
+        nsfw: undefined.UndefinedOr[bool] = undefined.UNDEFINED,
     ) -> data_binding.JSONObject:
         if guild is undefined.UNDEFINED:
             route = routes.POST_APPLICATION_COMMAND.compile(application=application)
@@ -3439,6 +3440,7 @@ async def _create_application_command(
         body.put_array("options", options, conversion=self._entity_factory.serialize_command_option)
         body.put("name_localizations", name_localizations)
         body.put("description_localizations", description_localizations)
+        body.put("nsfw", nsfw)
 
         # Discord has some funky behaviour around what 0 means. They consider it to be the same as ADMINISTRATOR,
         # but we consider it to be the same as None for developer sanity reasons
@@ -3467,6 +3469,7 @@ async def create_slash_command(
             undefined.UndefinedType, int, permissions_.Permissions
         ] = undefined.UNDEFINED,
         dm_enabled: undefined.UndefinedOr[bool] = undefined.UNDEFINED,
+        nsfw: undefined.UndefinedOr[bool] = undefined.UNDEFINED,
     ) -> commands.SlashCommand:
         response = await self._create_application_command(
             application=application,
@@ -3479,6 +3482,7 @@ async def create_slash_command(
             description_localizations=description_localizations,
             default_member_permissions=default_member_permissions,
             dm_enabled=dm_enabled,
+            nsfw=nsfw,
         )
         return self._entity_factory.deserialize_slash_command(
             response, guild_id=snowflakes.Snowflake(guild) if guild is not undefined.UNDEFINED else None
@@ -3498,6 +3502,7 @@ async def create_context_menu_command(
             undefined.UndefinedType, int, permissions_.Permissions
         ] = undefined.UNDEFINED,
         dm_enabled: undefined.UndefinedOr[bool] = undefined.UNDEFINED,
+        nsfw: undefined.UndefinedOr[bool] = undefined.UNDEFINED,
     ) -> commands.ContextMenuCommand:
         response = await self._create_application_command(
             application=application,
@@ -3507,6 +3512,7 @@ async def create_context_menu_command(
             name_localizations=name_localizations,
             default_member_permissions=default_member_permissions,
             dm_enabled=dm_enabled,
+            nsfw=nsfw,
         )
         return self._entity_factory.deserialize_context_menu_command(
             response, guild_id=snowflakes.Snowflake(guild) if guild is not undefined.UNDEFINED else None
@@ -3542,6 +3548,7 @@ async def edit_application_command(
             undefined.UndefinedType, int, permissions_.Permissions
         ] = undefined.UNDEFINED,
         dm_enabled: undefined.UndefinedOr[bool] = undefined.UNDEFINED,
+        nsfw: undefined.UndefinedOr[bool] = undefined.UNDEFINED,
     ) -> commands.PartialCommand:
         if guild is undefined.UNDEFINED:
             route = routes.PATCH_APPLICATION_COMMAND.compile(application=application, command=command)
@@ -3555,6 +3562,7 @@ async def edit_application_command(
         body.put("name", name)
         body.put("description", description)
         body.put_array("options", options, conversion=self._entity_factory.serialize_command_option)
+        body.put("nsfw", nsfw)
         # Discord has some funky behaviour around what 0 means. They consider it to be the same as ADMINISTRATOR,
         # but we consider it to be the same as None for developer sanity reasons
         body.put("default_member_permissions", None if default_member_permissions == 0 else default_member_permissions)
diff --git a/hikari/impl/special_endpoints.py b/hikari/impl/special_endpoints.py
index 6ee1d156..01ed2ae5 100644
--- a/hikari/impl/special_endpoints.py
+++ b/hikari/impl/special_endpoints.py
@@ -1196,6 +1196,7 @@ class CommandBuilder(special_endpoints.CommandBuilder):
         default=undefined.UNDEFINED, kw_only=True
     )
     _is_dm_enabled: undefined.UndefinedOr[bool] = attr.field(default=undefined.UNDEFINED, kw_only=True)
+    _is_nsfw: undefined.UndefinedOr[bool] = attr.field(default=undefined.UNDEFINED, kw_only=True)
 
     _name_localizations: typing.Mapping[typing.Union[locales.Locale, str], str] = attr.field(factory=dict, kw_only=True)
 
@@ -1211,6 +1212,10 @@ def default_member_permissions(self) -> typing.Union[undefined.UndefinedType, pe
     def is_dm_enabled(self) -> undefined.UndefinedOr[bool]:
         return self._is_dm_enabled
 
+    @property
+    def is_nsfw(self) -> undefined.UndefinedOr[bool]:
+        return self._is_dm_enabled
+
     @property
     def name(self) -> str:
         return self._name
@@ -1231,6 +1236,10 @@ def set_is_dm_enabled(self: _CommandBuilderT, state: undefined.UndefinedOr[bool]
         self._is_dm_enabled = state
         return self
 
+    def set_is_nsfw(self: _CommandBuilderT, state: undefined.UndefinedOr[bool], /) -> _CommandBuilderT:
+        self._is_nsfw = state
+        return self
+
     @property
     def name_localizations(self) -> typing.Mapping[typing.Union[locales.Locale, str], str]:
         return self._name_localizations
@@ -1250,6 +1259,7 @@ def build(self, _: entity_factory_.EntityFactory, /) -> typing.MutableMapping[st
         data.put_snowflake("id", self._id)
         data.put("name_localizations", self._name_localizations)
         data.put("dm_permission", self._is_dm_enabled)
+        data.put("nsfw", self._is_nsfw)
 
         # Discord considers 0 the same thing as ADMINISTRATORS, but we make it nicer to work with
         # by using it correctly.
@@ -1329,6 +1339,7 @@ async def create(
             description_localizations=self._description_localizations,
             default_member_permissions=self._default_member_permissions,
             dm_enabled=self._is_dm_enabled,
+            nsfw=self._is_nsfw,
         )
 
 
@@ -1361,6 +1372,7 @@ async def create(
             name_localizations=self._name_localizations,
             default_member_permissions=self._default_member_permissions,
             dm_enabled=self._is_dm_enabled,
+            nsfw=self.is_nsfw,
         )
 
 


Test patch:
diff --git a/tests/hikari/impl/test_entity_factory.py b/tests/hikari/impl/test_entity_factory.py
index decdc3a3..477e5c31 100644
--- a/tests/hikari/impl/test_entity_factory.py
+++ b/tests/hikari/impl/test_entity_factory.py
@@ -3750,7 +3750,8 @@ def slash_command_payload(self):
             "name": "good name",
             "description": "very good description",
             "default_member_permissions": 8,
-            "dm_permission": True,
+            "dm_permission": False,
+            "nsfw": True,
             "options": [
                 {
                     "type": 1,
@@ -3783,7 +3784,8 @@ def test_deserialize_slash_command(self, entity_factory_impl, mock_app, slash_co
         assert command.name == "good name"
         assert command.description == "very good description"
         assert command.default_member_permissions == permission_models.Permissions.ADMINISTRATOR
-        assert command.is_dm_enabled is True
+        assert command.is_dm_enabled is False
+        assert command.is_nsfw is True
         assert command.version == 123321123
 
         # CommandOption
@@ -3857,6 +3859,7 @@ def test_deserialize_slash_command_with_null_and_unset_values(self, entity_facto
 
         assert command.options is None
         assert command.is_dm_enabled is True
+        assert command.is_nsfw is False
         assert isinstance(command, commands.SlashCommand)
 
     def test_deserialize_slash_command_standardizes_default_member_permissions(
@@ -4436,7 +4439,8 @@ def context_menu_command_payload(self):
             "type": 2,
             "name": "good name",
             "default_member_permissions": 8,
-            "dm_permission": True,
+            "dm_permission": False,
+            "nsfw": True,
             "version": "123321123",
         }
 
@@ -4450,7 +4454,8 @@ def test_deserialize_context_menu_command(self, entity_factory_impl, context_men
         assert command.type == commands.CommandType.USER
         assert command.name == "good name"
         assert command.default_member_permissions == permission_models.Permissions.ADMINISTRATOR
-        assert command.is_dm_enabled is True
+        assert command.is_dm_enabled is False
+        assert command.is_nsfw is True
         assert command.version == 123321123
 
     def test_deserialize_context_menu_command_with_guild_id(
@@ -4467,18 +4472,21 @@ def test_deserialize_context_menu_command_with_guild_id(
         assert command.type == commands.CommandType.USER
         assert command.name == "good name"
         assert command.default_member_permissions == permission_models.Permissions.ADMINISTRATOR
-        assert command.is_dm_enabled is True
+        assert command.is_dm_enabled is False
+        assert command.is_nsfw is True
         assert command.version == 123321123
 
     def test_deserialize_context_menu_command_with_with_null_and_unset_values(
         self, entity_factory_impl, context_menu_command_payload
     ):
         del context_menu_command_payload["dm_permission"]
+        del context_menu_command_payload["nsfw"]
 
         command = entity_factory_impl.deserialize_context_menu_command(context_menu_command_payload)
         assert isinstance(command, commands.ContextMenuCommand)
 
         assert command.is_dm_enabled is True
+        assert command.is_nsfw is False
 
     def test_deserialize_context_menu_command_default_member_permissions(
         self, entity_factory_impl, context_menu_command_payload
diff --git a/tests/hikari/impl/test_rest.py b/tests/hikari/impl/test_rest.py
index b013b522..e6a111f1 100644
--- a/tests/hikari/impl/test_rest.py
+++ b/tests/hikari/impl/test_rest.py
@@ -5361,6 +5361,7 @@ async def test__create_application_command_with_optionals(self, rest_client: res
             options=[mock_option],
             default_member_permissions=permissions.Permissions.ADMINISTRATOR,
             dm_enabled=False,
+            nsfw=True,
         )
 
         assert result is rest_client._request.return_value
@@ -5374,6 +5375,7 @@ async def test__create_application_command_with_optionals(self, rest_client: res
                 "options": [rest_client._entity_factory.serialize_command_option.return_value],
                 "default_member_permissions": 8,
                 "dm_permission": False,
+                "nsfw": True,
             },
         )
 
@@ -5436,6 +5438,7 @@ async def test_create_slash_command(self, rest_client: rest.RESTClientImpl):
             description_localizations={locales.Locale.TR: "jello"},
             default_member_permissions=permissions.Permissions.ADMINISTRATOR,
             dm_enabled=False,
+            nsfw=True,
         )
 
         assert result is rest_client._entity_factory.deserialize_slash_command.return_value
@@ -5453,6 +5456,7 @@ async def test_create_slash_command(self, rest_client: rest.RESTClientImpl):
             description_localizations={"tr": "jello"},
             default_member_permissions=permissions.Permissions.ADMINISTRATOR,
             dm_enabled=False,
+            nsfw=True,
         )
 
     async def test_create_context_menu_command(self, rest_client: rest.RESTClientImpl):
@@ -5467,6 +5471,7 @@ async def test_create_context_menu_command(self, rest_client: rest.RESTClientImp
             guild=mock_guild,
             default_member_permissions=permissions.Permissions.ADMINISTRATOR,
             dm_enabled=False,
+            nsfw=True,
             name_localizations={locales.Locale.TR: "hhh"},
         )
 
@@ -5481,6 +5486,7 @@ async def test_create_context_menu_command(self, rest_client: rest.RESTClientImp
             guild=mock_guild,
             default_member_permissions=permissions.Permissions.ADMINISTRATOR,
             dm_enabled=False,
+            nsfw=True,
             name_localizations={"tr": "hhh"},
         )
 
diff --git a/tests/hikari/impl/test_special_endpoints.py b/tests/hikari/impl/test_special_endpoints.py
index 11e8947b..cf010e93 100644
--- a/tests/hikari/impl/test_special_endpoints.py
+++ b/tests/hikari/impl/test_special_endpoints.py
@@ -964,6 +964,7 @@ def test_build_with_optional_data(self):
             .set_id(3412312)
             .set_default_member_permissions(permissions.Permissions.ADMINISTRATOR)
             .set_is_dm_enabled(True)
+            .set_is_nsfw(True)
         )
 
         result = builder.build(mock_entity_factory)
@@ -974,6 +975,7 @@ def test_build_with_optional_data(self):
             "description": "one",
             "type": 1,
             "dm_permission": True,
+            "nsfw": True,
             "default_member_permissions": 8,
             "options": [mock_entity_factory.serialize_command_option.return_value],
             "id": "3412312",
@@ -1005,6 +1007,7 @@ async def test_create(self):
             .set_description_localizations({locales.Locale.TR: "bir"})
             .set_default_member_permissions(permissions.Permissions.BAN_MEMBERS)
             .set_is_dm_enabled(True)
+            .set_is_nsfw(True)
         )
         mock_rest = mock.AsyncMock()
 
@@ -1021,6 +1024,7 @@ async def test_create(self):
             description_localizations={locales.Locale.TR: "bir"},
             default_member_permissions=permissions.Permissions.BAN_MEMBERS,
             dm_enabled=True,
+            nsfw=True,
         )
 
     @pytest.mark.asyncio()
@@ -1029,6 +1033,7 @@ async def test_create_with_guild(self):
             special_endpoints.SlashCommandBuilder("we are number", "one")
             .set_default_member_permissions(permissions.Permissions.BAN_MEMBERS)
             .set_is_dm_enabled(True)
+            .set_is_nsfw(True)
         )
         mock_rest = mock.AsyncMock()
 
@@ -1048,6 +1053,7 @@ async def test_create_with_guild(self):
             description_localizations={locales.Locale.TR: "bir"},
             default_member_permissions=permissions.Permissions.BAN_MEMBERS,
             dm_enabled=True,
+            nsfw=True,
         )
 
 
@@ -1062,6 +1068,7 @@ def test_build_with_optional_data(self):
             .set_name_localizations({locales.Locale.TR: "merhaba"})
             .set_default_member_permissions(permissions.Permissions.ADMINISTRATOR)
             .set_is_dm_enabled(True)
+            .set_is_nsfw(True)
         )
 
         result = builder.build(mock.Mock())
@@ -1070,6 +1077,7 @@ def test_build_with_optional_data(self):
             "name": "we are number",
             "type": 2,
             "dm_permission": True,
+            "nsfw": True,
             "default_member_permissions": 8,
             "id": "3412312",
             "name_localizations": {locales.Locale.TR: "merhaba"},
@@ -1089,6 +1097,7 @@ async def test_create(self):
             .set_default_member_permissions(permissions.Permissions.BAN_MEMBERS)
             .set_name_localizations({"meow": "nyan"})
             .set_is_dm_enabled(True)
+            .set_is_nsfw(True)
         )
         mock_rest = mock.AsyncMock()
 
@@ -1103,6 +1112,7 @@ async def test_create(self):
             default_member_permissions=permissions.Permissions.BAN_MEMBERS,
             name_localizations={"meow": "nyan"},
             dm_enabled=True,
+            nsfw=True,
         )
 
     @pytest.mark.asyncio()
@@ -1112,6 +1122,7 @@ async def test_create_with_guild(self):
             .set_default_member_permissions(permissions.Permissions.BAN_MEMBERS)
             .set_name_localizations({"en-ghibli": "meow"})
             .set_is_dm_enabled(True)
+            .set_is_nsfw(True)
         )
         mock_rest = mock.AsyncMock()
 
@@ -1126,6 +1137,7 @@ async def test_create_with_guild(self):
             default_member_permissions=permissions.Permissions.BAN_MEMBERS,
             name_localizations={"en-ghibli": "meow"},
             dm_enabled=True,
+            nsfw=True,
         )
 
 
diff --git a/tests/hikari/test_commands.py b/tests/hikari/test_commands.py
index 76117ec9..9398e2d9 100644
--- a/tests/hikari/test_commands.py
+++ b/tests/hikari/test_commands.py
@@ -45,6 +45,7 @@ def mock_command(self, mock_app):
             name="Name",
             default_member_permissions=None,
             is_dm_enabled=False,
+            is_nsfw=True,
             guild_id=snowflakes.Snowflake(31231235),
             version=snowflakes.Snowflake(43123123),
         )