Repository: hikari-py/hikari
Pull request #340 (2020-11-04 14:59:52)

New mention object and small cleanups
### 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/hikari/impl/bot.py b/hikari/impl/bot.py
index 0876ab62..70f6f377 100644
--- a/hikari/impl/bot.py
+++ b/hikari/impl/bot.py
@@ -219,26 +219,21 @@ class BotApp(traits.BotAware, event_dispatcher.EventDispatcher):
     ```py
     import os
 
-    from logging.config import dictConfig
-    from hikari import Bot, Intents
-
-    bot = Bot(token=os.environ["BOT_TOKEN"], intents=Intents.ALL, logs="INFO")
+    import hikari
 
     # We want to make gateway logs output as DEBUG, and TRACE for all ratelimit content.
-    dictConfig({
-        "version": 1,
-        "incremental": True,
-        "loggers": {
-            "hikari.gateway": {
-                "level": "DEBUG"
-            },
-            "hikari.ratelimits": {
-                "level": "TRACE_HIKARI"
+    bot = hikari.Bot(
+        token=os.environ["BOT_TOKEN"],
+        logs={
+            "version": 1,
+            "incremental": True,
+            "loggers": {
+                "hikari.gateway": {"level": "DEBUG"},
+                "hikari.ratelimits": {"level": "TRACE_HIKARI"},
             },
         },
-    })
+    )
     ```
-
     """
 
     __slots__: typing.Sequence[str] = (
@@ -732,7 +727,7 @@ async def start(
         idle_since: typing.Optional[datetime.datetime] = None,
         ignore_session_start_limit: bool = False,
         large_threshold: int = 250,
-        shard_ids: typing.Optional[typing.Set[int]] = None,
+        shard_ids: typing.Optional[typing.AbstractSet[int]] = None,
         shard_count: typing.Optional[int] = None,
         status: presences.Status = presences.Status.ONLINE,
     ) -> None:
diff --git a/hikari/impl/entity_factory.py b/hikari/impl/entity_factory.py
index bdfddc2a..927ff56c 100644
--- a/hikari/impl/entity_factory.py
+++ b/hikari/impl/entity_factory.py
@@ -1449,7 +1449,9 @@ def deserialize_invite_with_metadata(self, payload: data_binding.JSONObject) ->
     # MESSAGE MODELS #
     ##################
 
-    def deserialize_partial_message(self, payload: data_binding.JSONObject) -> message_models.PartialMessage:
+    def deserialize_partial_message(  # noqa CFQ001 - Function too long
+        self, payload: data_binding.JSONObject
+    ) -> message_models.PartialMessage:
         author: typing.Optional[user_models.User] = None
         if author_pl := payload.get("author"):
             author = self.deserialize_user(author_pl)
@@ -1473,20 +1475,6 @@ def deserialize_partial_message(self, payload: data_binding.JSONObject) -> messa
             else:
                 edited_timestamp = None
 
-        is_mentioning_everyone = payload["mention_everyone"] if "mention_everyone" in payload else undefined.UNDEFINED
-
-        user_mentions: undefined.UndefinedOr[typing.Sequence[snowflakes.Snowflake]] = undefined.UNDEFINED
-        if "mentions" in payload:
-            user_mentions = [snowflakes.Snowflake(mention["id"]) for mention in payload["mentions"]]
-
-        role_mentions: undefined.UndefinedOr[typing.Sequence[snowflakes.Snowflake]] = undefined.UNDEFINED
-        if "mention_roles" in payload:
-            role_mentions = [snowflakes.Snowflake(mention) for mention in payload["mention_roles"]]
-
-        channel_mentions: undefined.UndefinedOr[typing.Sequence[snowflakes.Snowflake]] = undefined.UNDEFINED
-        if "mention_channels" in payload:
-            channel_mentions = [snowflakes.Snowflake(mention["id"]) for mention in payload["mention_channels"]]
-
         attachments: undefined.UndefinedOr[typing.MutableSequence[message_models.Attachment]] = undefined.UNDEFINED
         if "attachments" in payload:
             attachments = []
@@ -1554,7 +1542,7 @@ def deserialize_partial_message(self, payload: data_binding.JSONObject) -> messa
                 else None,
             )
 
-        return message_models.PartialMessage(
+        message = message_models.PartialMessage(
             app=self._app,
             id=snowflakes.Snowflake(payload["id"]),
             channel_id=snowflakes.Snowflake(payload["channel_id"]),
@@ -1565,10 +1553,6 @@ def deserialize_partial_message(self, payload: data_binding.JSONObject) -> messa
             timestamp=timestamp,
             edited_timestamp=edited_timestamp,
             is_tts=payload["tts"] if "tts" in payload else undefined.UNDEFINED,
-            is_mentioning_everyone=is_mentioning_everyone,
-            user_mentions=user_mentions,
-            role_mentions=role_mentions,
-            channel_mentions=channel_mentions,
             attachments=attachments,
             embeds=embeds,
             reactions=reactions,
@@ -1580,8 +1564,36 @@ def deserialize_partial_message(self, payload: data_binding.JSONObject) -> messa
             message_reference=message_reference,
             flags=message_models.MessageFlag(payload["flags"]) if "flags" in payload else undefined.UNDEFINED,
             nonce=payload["nonce"] if "nonce" in payload else undefined.UNDEFINED,
+            # We initialize these next.
+            mentions=NotImplemented,
+        )
+
+        channels: undefined.UndefinedOr[typing.Mapping[snowflakes.Snowflake, channel_models.PartialChannel]]
+        channels = undefined.UNDEFINED
+        if raw_channels := payload.get("mention_channels"):
+            channels = {c.id: c for c in map(self.deserialize_partial_channel, raw_channels)}
+
+        users: undefined.UndefinedOr[typing.Mapping[snowflakes.Snowflake, user_models.User]]
+        users = undefined.UNDEFINED
+        if raw_users := payload.get("mentions"):
+            users = {u.id: u for u in map(self.deserialize_user, raw_users)}
+
+        role_ids: undefined.UndefinedOr[typing.Sequence[snowflakes.Snowflake]] = undefined.UNDEFINED
+        if raw_role_ids := payload.get("mention_roles"):
+            role_ids = [snowflakes.Snowflake(i) for i in raw_role_ids]
+
+        everyone = payload.get("mention_everyone", undefined.UNDEFINED)
+
+        message.mentions = message_models.Mentions(
+            message=message,
+            users=users,
+            role_ids=role_ids,
+            channels=channels,
+            everyone=everyone,
         )
 
+        return message
+
     def deserialize_message(self, payload: data_binding.JSONObject) -> message_models.Message:
         guild_id = snowflakes.Snowflake(payload["guild_id"]) if "guild_id" in payload else None
         author = self.deserialize_user(payload["author"])
@@ -1595,14 +1607,6 @@ def deserialize_message(self, payload: data_binding.JSONObject) -> message_model
         if (raw_edited_timestamp := payload["edited_timestamp"]) is not None:
             edited_timestamp = time.iso8601_datetime_string_to_datetime(raw_edited_timestamp)
 
-        user_mentions = [snowflakes.Snowflake(mention["id"]) for mention in payload["mentions"]]
-        role_mentions = [snowflakes.Snowflake(mention) for mention in payload["mention_roles"]]
-        channel_mentions = (
-            [snowflakes.Snowflake(mention["id"]) for mention in payload["mention_channels"]]
-            if "mention_channels" in payload
-            else []
-        )
-
         attachments = []
         for attachment_payload in payload["attachments"]:
             attachment = message_models.Attachment(
@@ -1668,7 +1672,7 @@ def deserialize_message(self, payload: data_binding.JSONObject) -> message_model
                 primary_sku_id=primary_sku_id,
             )
 
-        return message_models.Message(
+        message = message_models.Message(
             app=self._app,
             id=snowflakes.Snowflake(payload["id"]),
             channel_id=snowflakes.Snowflake(payload["channel_id"]),
@@ -1679,10 +1683,6 @@ def deserialize_message(self, payload: data_binding.JSONObject) -> message_model
             timestamp=time.iso8601_datetime_string_to_datetime(payload["timestamp"]),
             edited_timestamp=edited_timestamp,
             is_tts=payload["tts"],
-            is_mentioning_everyone=payload["mention_everyone"],
-            user_mentions=user_mentions,
-            role_mentions=role_mentions,
-            channel_mentions=channel_mentions,
             attachments=attachments,
             embeds=embeds,
             reactions=reactions,
@@ -1694,8 +1694,34 @@ def deserialize_message(self, payload: data_binding.JSONObject) -> message_model
             message_reference=crosspost,
             flags=message_models.MessageFlag(payload["flags"]) if "flags" in payload else None,
             nonce=payload.get("nonce"),
+            # We initialize these next.
+            mentions=NotImplemented,
         )
 
+        channels: typing.Mapping[snowflakes.Snowflake, channel_models.PartialChannel] = {}
+        if raw_channels := payload.get("mention_channels"):
+            channels = {c.id: c for c in map(self.deserialize_partial_channel, raw_channels)}
+
+        users: typing.Mapping[snowflakes.Snowflake, user_models.User] = {}
+        if raw_users := payload.get("mentions"):
+            users = {u.id: u for u in map(self.deserialize_user, raw_users)}
+
+        role_ids: typing.Sequence[snowflakes.Snowflake] = []
+        if raw_role_ids := payload.get("mention_roles"):
+            role_ids = [snowflakes.Snowflake(i) for i in raw_role_ids]
+
+        everyone = payload.get("mention_everyone", False)
+
+        message.mentions = message_models.Mentions(
+            message=message,
+            users=users,
+            role_ids=role_ids,
+            channels=channels,
+            everyone=everyone,
+        )
+
+        return message
+
     ###################
     # PRESENCE MODELS #
     ###################
diff --git a/hikari/internal/collections.py b/hikari/internal/collections.py
index aad47a8b..d5de5ec5 100644
--- a/hikari/internal/collections.py
+++ b/hikari/internal/collections.py
@@ -23,7 +23,7 @@
 from __future__ import annotations
 
 __all__: typing.List[str] = [
-    "MapT",
+    "ExtendedMapT",
     "KeyT",
     "ValueT",
     "SnowflakeSet",
@@ -46,7 +46,7 @@
 
 from hikari import snowflakes
 
-MapT = typing.TypeVar("MapT", bound="ExtendedMutableMapping[typing.Any, typing.Any]")
+ExtendedMapT = typing.TypeVar("ExtendedMapT", bound="ExtendedMutableMapping[typing.Any, typing.Any]")
 """Type-hint A type hint used for mapped collection objects."""
 KeyT = typing.TypeVar("KeyT", bound=typing.Hashable)
 """Type-hint A type hint used for the type of a mapping's key."""
@@ -65,7 +65,7 @@ class ExtendedMutableMapping(typing.MutableMapping[KeyT, ValueT], abc.ABC):
     __slots__: typing.Sequence[str] = ()
 
     @abc.abstractmethod
-    def copy(self: MapT) -> MapT:
+    def copy(self: ExtendedMapT) -> ExtendedMapT:
         """Return a copy of this mapped collection.
 
         Unlike simply doing `dict(mapping)`, this may rely on internal detail
diff --git a/hikari/messages.py b/hikari/messages.py
index 8b203898..32f50de3 100644
--- a/hikari/messages.py
+++ b/hikari/messages.py
@@ -30,6 +30,7 @@
     "Attachment",
     "Reaction",
     "MessageActivity",
+    "Mentions",
     "MessageCrosspost",
     "PartialMessage",
     "Message",
@@ -41,7 +42,9 @@
 
 from hikari import files
 from hikari import guilds
+from hikari import intents
 from hikari import snowflakes
+from hikari import traits
 from hikari import undefined
 from hikari import urls
 from hikari.internal import attr_extensions
@@ -54,8 +57,9 @@
     from hikari import channels
     from hikari import embeds as embeds_
     from hikari import emojis as emojis_
-    from hikari import traits
-    from hikari import users
+    from hikari import users as users_
+
+_T = typing.TypeVar("_T")
 
 
 @typing.final
@@ -209,6 +213,136 @@ class MessageActivity:
     """The party ID of the message activity."""
 
 
+@attr_extensions.with_copy
+@attr.s(eq=True, hash=False, init=True, kw_only=True, slots=True, weakref_slot=False)
+class Mentions:
+    """Description of mentions that exist in the message."""
+
+    # We refer back to the containing message so that we can provide info about
+    # entities that were not notified, and provide access to cached roles
+    # through this mechanism.
+    _message: PartialMessage = attr.ib()
+
+    users: undefined.UndefinedOr[typing.Mapping[snowflakes.Snowflake, users_.User]] = attr.ib()
+    """Users who were notified by their mention in the message."""
+
+    role_ids: undefined.UndefinedOr[typing.Sequence[snowflakes.Snowflake]] = attr.ib()
+    """IDs of roles that were notified by their mention in the message."""
+
+    channels: undefined.UndefinedOr[typing.Mapping[snowflakes.Snowflake, channels.PartialChannel]] = attr.ib()
+    """Channel mentions that reference channels in the target crosspost's guild.
+
+    If the message is not crossposted, this will always be empty.
+    """
+
+    everyone: undefined.UndefinedOr[bool] = attr.ib()
+    """Whether the message notifies using `@everyone` or `@here`."""
+
+    @property
+    def channels_ids(self) -> undefined.UndefinedOr[typing.Sequence[snowflakes.Snowflake]]:
+        if self.channels is undefined.UNDEFINED:
+            return undefined.UNDEFINED
+
+        return list(self.channels.keys())
+
+    @property
+    def user_ids(self) -> undefined.UndefinedOr[typing.Sequence[snowflakes.Snowflake]]:
+        if self.users is undefined.UNDEFINED:
+            return undefined.UNDEFINED
+
+        return list(self.users.keys())
+
+    @property
+    def members(self) -> undefined.UndefinedOr[typing.Mapping[snowflakes.Snowflake, guilds.Member]]:
+        """Discover any cached members notified by this message.
+
+        If this message was sent in a DM, this will always be empty.
+
+        !!! warning
+            This will only return valid results on gateway events. For REST
+            endpoints, this will potentially be empty. This is a limitation of
+            Discord's API, as they do not consistently notify of the ID of the
+            guild a message was sent in.
+
+        !!! note
+            If you are using a stateless application such as a stateless bot
+            or a REST-only client, this will always be empty. Furthermore,
+            if you are running a stateful bot and have the GUILD_MEMBERS
+            intent disabled, this will also be empty.
+
+            Members that are not cached will not appear in this mapping. This
+            means that there is a very small chance that some users provided
+            in `notified_users` may not be present here.
+        """
+        if self.users is undefined.UNDEFINED:
+            return undefined.UNDEFINED
+
+        if self._ensure_cache(intents.Intents.GUILDS, True):
+            guild_id = typing.cast(snowflakes.Snowflake, self._message.guild_id)
+            app = typing.cast(traits.CacheAware, self._message.app)
+
+            return self._map_cache_maybe_discover(
+                self.users,
+                lambda user_id: app.cache.get_member(guild_id, user_id),
+            )
+
+        return {}
+
+    @property
+    def roles(self) -> typing.Mapping[snowflakes.Snowflake, guilds.Role]:
+        """Attempt to look up the roles that are notified by this message.
+
+        If this message was sent in a DM, this will always be empty.
+
+        !!! warning
+            This will only return valid results on gateway events. For REST
+            endpoints, this will potentially be empty. This is a limitation of
+            Discord's API, as they do not consistently notify of the ID of the
+            guild a message was sent in.
+
+        !!! note
+            If you are using a stateless application such as a stateless bot
+            or a REST-only client, this will always be empty. Furthermore,
+            if you are running a stateful bot and have the GUILD intent
+            disabled, this will also be empty.
+
+            Roles that are not cached will not appear in this mapping. This
+            means that there is a very small chance that some role IDs provided
+            in `notifies_role_ids` may not be present here. This is a limitation
+            of Discord, again.
+        """
+        if self._ensure_cache(intents.Intents.GUILDS, True):
+            app = typing.cast(traits.CacheAware, self._message.app)
+
+            return self._map_cache_maybe_discover(
+                self.roles,
+                app.cache.get_role,
+            )
+
+        return {}
+
+    def _ensure_cache(self, intents_required: intents.Intents, needs_guild: bool) -> bool:
+        app = self._message.app
+
+        return bool(
+            isinstance(app, traits.ShardAware)
+            and (app.intents & intents_required) == intents_required
+            and (not needs_guild or self._message.guild_id)
+        )
+
+    @staticmethod
+    def _map_cache_maybe_discover(
+        ids: typing.Iterable[snowflakes.Snowflake],
+        cache_call: typing.Callable[[snowflakes.Snowflake], typing.Optional[_T]],
+    ) -> typing.Dict[snowflakes.Snowflake, _T]:
+        results: typing.Dict[snowflakes.Snowflake, _T] = {}
+        for id_ in ids:
+            obj = cache_call(id_)
+            if obj is not None:
+                results[id_] = obj
+        return results
+
+
 @attr_extensions.with_copy
 @attr.s(eq=True, hash=False, init=True, kw_only=True, slots=True, weakref_slot=False)
 class MessageCrosspost:
@@ -326,10 +460,11 @@ class PartialMessage(snowflakes.Unique):
     channel_id: snowflakes.Snowflake = attr.ib(repr=True)
     """The ID of the channel that the message was sent in."""
 
-    guild_id: typing.Optional[snowflakes.Snowflake] = attr.ib(repr=True)
-    """The ID of the guild that the message was sent in."""
+    _guild_id: typing.Optional[snowflakes.Snowflake] = attr.ib(repr=True)
+    #: Try to determine this best-effort in the property defined further
+    #: down.
 
-    author: typing.Optional[users.User] = attr.ib(repr=True)
+    author: typing.Optional[users_.User] = attr.ib(repr=True)
     """The author of this message.
 
     This will be `builtins.None` in some cases such as when Discord
@@ -361,18 +496,15 @@ class PartialMessage(snowflakes.Unique):
     is_tts: undefined.UndefinedOr[bool] = attr.ib(repr=False)
     """Whether the message is a TTS message."""
 
-    is_mentioning_everyone: undefined.UndefinedOr[bool] = attr.ib(repr=False)
-    """Whether the message mentions `@everyone` or `@here`."""
-
-    # TODO: make a mentions object. These type hints are cancer in the documentation.
-    user_mentions: undefined.UndefinedOr[typing.Sequence[snowflakes.Snowflake]] = attr.ib(repr=False)
-    """The users the message mentions."""
+    mentions: Mentions = attr.ib(repr=True)
+    """Description of who is mentioned in a message.
 
-    role_mentions: undefined.UndefinedOr[typing.Sequence[snowflakes.Snowflake]] = attr.ib(repr=False)
-    """The roles the message mentions."""
+    !!! warning
+        If the contents have not mutated and this is a message update event,
+        some fields that are not affected may be empty instead.
 
-    channel_mentions: undefined.UndefinedOr[typing.Sequence[snowflakes.Snowflake]] = attr.ib(repr=False)
-    """The channels the message mentions."""
+        This is a Discord limitation.
+    """
 
     attachments: undefined.UndefinedOr[typing.Sequence[Attachment]] = attr.ib(repr=False)
     """The message attachments."""
@@ -410,6 +542,25 @@ class PartialMessage(snowflakes.Unique):
     This is a string used for validating a message was sent.
     """
 
+    @property
+    def guild_id(self) -> typing.Optional[snowflakes.Snowflake]:
+        """ID of the guild that the message was sent in.
+
+        This will not be present on REST API responses if the application is
+        stateless or missing the `GUILDS` intent.
+        """
+        if self._guild_id:
+            return self._guild_id
+
+        # Don't check the member, as if the guild_id is missing, the member
+        # will always be missing too.
+        channel = self.app.cache.get_guild_channel(self.channel_id)
+
+        if channel is None:
+            return None
+
+        return channel.guild_id
+
     @property
     def link(self) -> str:
         """Jump link to the message.
@@ -449,7 +600,7 @@ async def edit(
         embed: undefined.UndefinedNoneOr[embeds_.Embed] = undefined.UNDEFINED,
         mentions_everyone: undefined.UndefinedOr[bool] = undefined.UNDEFINED,
         user_mentions: undefined.UndefinedOr[
-            typing.Union[typing.Collection[snowflakes.SnowflakeishOr[users.PartialUser]], bool]
+            typing.Union[typing.Collection[snowflakes.SnowflakeishOr[users_.PartialUser]], bool]
         ] = undefined.UNDEFINED,
         role_mentions: undefined.UndefinedOr[
             typing.Union[typing.Collection[snowflakes.SnowflakeishOr[guilds.PartialRole]], bool]
@@ -578,7 +729,7 @@ async def reply(
         nonce: undefined.UndefinedOr[str] = undefined.UNDEFINED,
         mentions_everyone: undefined.UndefinedOr[bool] = undefined.UNDEFINED,
         user_mentions: undefined.UndefinedOr[
-            typing.Union[typing.Collection[snowflakes.SnowflakeishOr[users.PartialUser]], bool]
+            typing.Union[typing.Collection[snowflakes.SnowflakeishOr[users_.PartialUser]], bool]
         ] = undefined.UNDEFINED,
         role_mentions: undefined.UndefinedOr[
             typing.Union[typing.Collection[snowflakes.SnowflakeishOr[guilds.PartialRole]], bool]
@@ -624,9 +775,8 @@ async def reply(
             If provided, and `builtins.True`, all mentions will be parsed.
             If provided, and `builtins.False`, no mentions will be parsed.
             Alternatively this may be a collection of
-            `hikari.snowflakes.Snowflake`, or
-            `hikari.users.PartialUser` derivatives to enforce mentioning
-            specific users.
+            `hikari.snowflakes.Snowflake`, or `hikari.users.PartialUser`
+            derivatives to enforce mentioning specific users.
         role_mentions : hikari.undefined.UndefinedOr[typing.Union[typing.Collection[hikari.snowflakes.SnowflakeishOr[hikari.guilds.PartialRole]], builtins.bool]]
             If provided, and `builtins.True`, all mentions will be parsed.
             If provided, and `builtins.False`, no mentions will be parsed.
@@ -776,7 +926,7 @@ async def remove_reaction(
         self,
         emoji: emojis_.Emojiish,
         *,
-        user: undefined.UndefinedOr[snowflakes.SnowflakeishOr[users.PartialUser]] = undefined.UNDEFINED,
+        user: undefined.UndefinedOr[snowflakes.SnowflakeishOr[users_.PartialUser]] = undefined.UNDEFINED,
     ) -> None:
         r"""Remove a reaction from this message.
 
@@ -883,10 +1033,7 @@ class Message(PartialMessage):
     # and possibly result in large amounts of unwasted memory if you get that
     # far.
 
-    guild_id: typing.Optional[snowflakes.Snowflake]
-    """The ID of the guild that the message was sent in."""
-
-    author: users.User
+    author: users_.User
     """The author of this message."""
 
     member: typing.Optional[guilds.Member]
@@ -907,17 +1054,8 @@ class Message(PartialMessage):
     is_tts: bool
     """Whether the message is a TTS message."""
 
-    is_mentioning_everyone: bool
-    """Whether the message mentions `@everyone` or `@here`."""
-
-    user_mentions: typing.Sequence[snowflakes.Snowflake]
-    """The users the message mentions."""
-
-    role_mentions: typing.Sequence[snowflakes.Snowflake]
-    """The roles the message mentions."""
-
-    channel_mentions: typing.Sequence[snowflakes.Snowflake]
-    """The channels the message mentions."""
+    mentions: Mentions
+    """Who is mentioned in a message."""
 
     attachments: typing.Sequence[Attachment]
     """The message attachments."""


Test patch:
diff --git a/tests/hikari/impl/test_entity_factory.py b/tests/hikari/impl/test_entity_factory.py
index 35d19624..868285f4 100644
--- a/tests/hikari/impl/test_entity_factory.py
+++ b/tests/hikari/impl/test_entity_factory.py
@@ -2465,7 +2465,7 @@ def test_deserialize_partial_message(
         assert partial_message.app is mock_app
         assert partial_message.id == 123
         assert partial_message.channel_id == 456
-        assert partial_message.guild_id == 678
+        assert partial_message._guild_id == 678
         assert partial_message.author == entity_factory_impl.deserialize_user(user_payload)
         assert partial_message.member == entity_factory_impl.deserialize_member(
             member_payload, user=partial_message.author, guild_id=snowflakes.Snowflake(678)
@@ -2478,10 +2478,10 @@ def test_deserialize_partial_message(
             2020, 4, 21, 21, 20, 16, 510000, tzinfo=datetime.timezone.utc
         )
         assert partial_message.is_tts is True
-        assert partial_message.is_mentioning_everyone is True
-        assert partial_message.user_mentions == [5678]
-        assert partial_message.role_mentions == [987]
-        assert partial_message.channel_mentions == [456]
+        assert partial_message.mentions.everyone is True
+        assert partial_message.mentions.user_ids == [5678]
+        assert partial_message.mentions.role_ids == [987]
+        assert partial_message.mentions.channels_ids == [456]
         # Attachment
         assert len(partial_message.attachments) == 1
         attachment = partial_message.attachments[0]
@@ -2537,7 +2537,7 @@ def test_deserialize_partial_message_with_partial_fields(self, entity_factory_im
         message_payload["application"]["primary_sku_id"] = None
         partial_message = entity_factory_impl.deserialize_partial_message(message_payload)
         assert partial_message.edited_timestamp is None
-        assert partial_message.guild_id is not None
+        assert partial_message._guild_id is not None
         assert partial_message.member is None
         assert partial_message.application.primary_sku_id is None
 
@@ -2546,17 +2546,17 @@ def test_deserialize_partial_message_with_unset_fields(self, entity_factory_impl
         assert partial_message.app is mock_app
         assert partial_message.id == 123
         assert partial_message.channel_id == 456
-        assert partial_message.guild_id is None
+        assert partial_message._guild_id is None
         assert partial_message.author is None
         assert partial_message.member is None
         assert partial_message.content is undefined.UNDEFINED
         assert partial_message.timestamp is undefined.UNDEFINED
         assert partial_message.edited_timestamp is undefined.UNDEFINED
         assert partial_message.is_tts is undefined.UNDEFINED
-        assert partial_message.is_mentioning_everyone is undefined.UNDEFINED
-        assert partial_message.user_mentions is undefined.UNDEFINED
-        assert partial_message.role_mentions is undefined.UNDEFINED
-        assert partial_message.channel_mentions is undefined.UNDEFINED
+        assert partial_message.mentions.everyone is undefined.UNDEFINED
+        assert partial_message.mentions.user_ids is undefined.UNDEFINED
+        assert partial_message.mentions.role_ids is undefined.UNDEFINED
+        assert partial_message.mentions.channels_ids is undefined.UNDEFINED
         assert partial_message.attachments is undefined.UNDEFINED
         assert partial_message.embeds is undefined.UNDEFINED
         assert partial_message.reactions is undefined.UNDEFINED
@@ -2584,7 +2584,7 @@ def test_deserialize_full_message(
         assert message.app is mock_app
         assert message.id == 123
         assert message.channel_id == 456
-        assert message.guild_id == 678
+        assert message._guild_id == 678
         assert message.author == entity_factory_impl.deserialize_user(user_payload)
         assert message.member == entity_factory_impl.deserialize_member(
             member_payload, user=message.author, guild_id=snowflakes.Snowflake(678)
@@ -2595,10 +2595,10 @@ def test_deserialize_full_message(
             2020, 4, 21, 21, 20, 16, 510000, tzinfo=datetime.timezone.utc
         )
         assert message.is_tts is True
-        assert message.is_mentioning_everyone is True
-        assert message.user_mentions == [5678]
-        assert message.role_mentions == [987]
-        assert message.channel_mentions == [456]
+        assert message.mentions.everyone is True
+        assert message.mentions.user_ids == [5678]
+        assert message.mentions.role_ids == [987]
+        assert message.mentions.channels_ids == [456]
         # Attachment
         assert len(message.attachments) == 1
         attachment = message.attachments[0]
@@ -2674,12 +2674,13 @@ def test_deserialize_message_with_null_and_unset_fields(
 
         message = entity_factory_impl.deserialize_message(message_payload)
         assert message.app is mock_app
-        assert message.guild_id is None
+        assert message._guild_id is None
         assert message.member is None
         assert message.edited_timestamp is None
-        assert message.channel_mentions == []
-        assert message.role_mentions == []
-        assert message.channel_mentions == []
+        assert message.mentions.everyone is True
+        assert message.mentions.user_ids == []
+        assert message.mentions.role_ids == []
+        assert message.mentions.channels_ids == []
         assert message.attachments == []
         assert message.embeds == []
         assert message.reactions == []
diff --git a/tests/hikari/test_messages.py b/tests/hikari/test_messages.py
index 3c3e51e3..55ce6a6b 100644
--- a/tests/hikari/test_messages.py
+++ b/tests/hikari/test_messages.py
@@ -100,7 +100,7 @@ def test_format_cover_image_when_hash_is_not_none(self, message_application):
 @pytest.fixture()
 def message():
     return messages.Message(
-        app=mock.AsyncMock(),
+        app=None,
         id=snowflakes.Snowflake(1234),
         channel_id=snowflakes.Snowflake(5678),
         guild_id=snowflakes.Snowflake(910112),
@@ -110,10 +110,13 @@ def message():
         timestamp=datetime.datetime.now().astimezone(),
         edited_timestamp=None,
         is_tts=False,
-        is_mentioning_everyone=False,
-        user_mentions=(),
-        role_mentions=(),
-        channel_mentions=(),
+        mentions=messages.Mentions(
+            message=mock.Mock(),
+            users={},
+            role_ids=[],
+            channels={},
+            everyone=False,
+        ),
         attachments=(),
         embeds=(),
         reactions=(),
@@ -132,25 +135,43 @@ class TestMessage:
     def test_link_property_when_guild_is_not_none(self, message):
         message.id = 789
         message.channel_id = 456
-        message.guild_id = 123
+        message._guild_id = 123
         assert message.link == "https://discord.com/channels/123/456/789"
 
     def test_link_property_when_guild_is_none(self, message):
+        message.app = mock.Mock()
         message.id = 789
         message.channel_id = 456
-        message.guild_id = None
+        message._guild_id = None
+        message.app.cache.get_guild_channel.return_value = None
         assert message.link == "https://discord.com/channels/@me/456/789"
 
+    def test_guild_id_when_guild_is_not_none(self, message):
+        message._guild_id = 123
+
+        assert message.guild_id == 123
+
+    def test_guild_id_when_guild_is_none(self, message):
+        message.app = mock.Mock()
+        message._guild_id = None
+        message.channel_id = 890
+        message.app.cache.get_guild_channel = mock.Mock(return_value=mock.Mock(guild_id=456))
+
+        assert message.guild_id == 456
+
+        message.app.cache.get_guild_channel.assert_called_once_with(890)
+
 
-# TODO: this all needs to be moved to one test class.
 @pytest.mark.asyncio
 class TestAsyncMessage:
     async def test_fetch_channel(self, message):
+        message.app = mock.AsyncMock()
         message.channel_id = 123
         await message.fetch_channel()
         message.app.rest.fetch_channel.assert_awaited_once_with(123)
 
     async def test_edit(self, message):
+        message.app = mock.AsyncMock()
         message.id = 123
         message.channel_id = 456
         embed = object()
@@ -175,6 +196,7 @@ async def test_edit(self, message):
         )
 
     async def test_reply(self, message):
+        message.app = mock.AsyncMock()
         message.id = 123
         message.channel_id = 456
         embed = object()
@@ -206,24 +228,28 @@ async def test_reply(self, message):
         )
 
     async def test_delete(self, message):
+        message.app = mock.AsyncMock()
         message.id = 123
         message.channel_id = 456
         await message.delete()
         message.app.rest.delete_message.assert_awaited_once_with(456, 123)
 
     async def test_add_reaction(self, message):
+        message.app = mock.AsyncMock()
         message.id = 123
         message.channel_id = 456
         await message.add_reaction("👌")
         message.app.rest.add_reaction.assert_awaited_once_with(channel=456, message=123, emoji="👌")
 
     async def test_remove_reaction(self, message):
+        message.app = mock.AsyncMock()
         message.id = 123
         message.channel_id = 456
         await message.remove_reaction("👌")
         message.app.rest.delete_my_reaction.assert_awaited_once_with(channel=456, message=123, emoji="👌")
 
     async def test_remove_reaction_with_user(self, message):
+        message.app = mock.AsyncMock()
         user = object()
         message.id = 123
         message.channel_id = 456
@@ -231,12 +257,14 @@ async def test_remove_reaction_with_user(self, message):
         message.app.rest.delete_reaction.assert_awaited_once_with(channel=456, message=123, emoji="👌", user=user)
 
     async def test_remove_all_reactions(self, message):
+        message.app = mock.AsyncMock()
         message.id = 123
         message.channel_id = 456
         await message.remove_all_reactions()
         message.app.rest.delete_all_reactions.assert_awaited_once_with(channel=456, message=123)
 
     async def test_remove_all_reactions_with_emoji(self, message):
+        message.app = mock.AsyncMock()
         message.id = 123
         message.channel_id = 456
         await message.remove_all_reactions("👌")