Repository: hikari-py/hikari
Pull request #661 (2021-06-13 19:27:44)

Delay initialisation of BotApp._closing_event and use it as ._closed
### Summary
Delay initialisation of BotApp._closing_event and use it as ._closed
* This also solves the potential issue where BotApp._closed was never re-set to `False` once a bot was re-started

### 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.

### Related issues
This contributes to #625 


Patch:
diff --git a/hikari/api/cache.py b/hikari/api/cache.py
index 013fe6a8..d86cd989 100644
--- a/hikari/api/cache.py
+++ b/hikari/api/cache.py
@@ -78,6 +78,34 @@ class Cache(abc.ABC):
 
     __slots__: typing.Sequence[str] = ()
 
+    @abc.abstractmethod
+    def get_dm_channel_id(
+        self, user: snowflakes.SnowflakeishOr[users.PartialUser], /
+    ) -> typing.Optional[snowflakes.Snowflake]:
+        """Get the DM channel ID for a user.
+
+        Parameters
+        ----------
+        user : hikari.snowflakes.SnowflakeishOr[hikari.users.PartialUser]
+            Objet or ID of the user to get the DM channel ID for.
+
+        Returns
+        -------
+        typing.Optional[hikari.snowflakes.Snowflake]
+            ID of the DM channel which was found cached for the supplied user or
+            `builtins.None`.
+        """
+
+    @abc.abstractmethod
+    def get_dm_channel_ids_view(self) -> CacheView[snowflakes.Snowflake, snowflakes.Snowflake]:
+        """Get a view of the cached DM channel IDs.
+
+        Returns
+        -------
+        CacheView[hikari.snowflakes.Snowflake, hikari.snowflakes.Snowflake]
+            Cache view of user IDs to DM channel IDs.
+        """
+
     @abc.abstractmethod
     def get_emoji(
         self, emoji: snowflakes.SnowflakeishOr[emojis.CustomEmoji], /
@@ -638,6 +666,52 @@ class MutableCache(Cache, abc.ABC):
     def clear(self) -> None:
         """Clear the full cache."""
 
+    @abc.abstractmethod
+    def clear_dm_channel_ids(self) -> CacheView[snowflakes.Snowflake, snowflakes.Snowflake]:
+        """Remove all the cached DM channel IDs.
+
+        Returns
+        -------
+        CacheView[hikari.snowflakes.Snowflake, hikari.snowflakes.Snowflake]
+            Cache view of user IDs to DM channel IDs which were cleared from
+            the cache.
+        """
+
+    @abc.abstractmethod
+    def delete_dm_channel_id(
+        self, user: snowflakes.SnowflakeishOr[users.PartialUser], /
+    ) -> typing.Optional[snowflakes.Snowflake]:
+        """Remove a DM channel ID from the cache.
+
+        Parameters
+        ----------
+        user : hikari.snowflakes.SnowflakeishOr[hikari.users.PartialUser]
+            Object or ID of the user to remove the cached DM channel ID for.
+
+        Returns
+        -------
+        typing.Optional[hikari.snowflakes.Snowflake]
+            The DM channel ID which was removed from the cache if found, else
+            `builtins.None`.
+        """
+
+    @abc.abstractmethod
+    def set_dm_channel_id(
+        self,
+        user: snowflakes.SnowflakeishOr[users.PartialUser],
+        channel: snowflakes.SnowflakeishOr[channels.PartialChannel],
+        /,
+    ) -> None:
+        """Add a DM channel ID to the cache.
+
+        Parameters
+        ----------
+        user : hikari.snowflakes.SnowflakeishOr[hikari.users.PartialUser]
+            Object or ID of the user to add a DM channel ID to the cache for.
+        channel : hikari.snowflakes.SnowflakeishOr[hikari.channels.PartialChannel]
+            Object or ID of the DM channel to add to the cache.
+        """
+
     @abc.abstractmethod
     def clear_emojis(self) -> CacheView[snowflakes.Snowflake, emojis.KnownCustomEmoji]:
         """Remove all the known custom emoji objects from the cache.
diff --git a/hikari/applications.py b/hikari/applications.py
index 96fd149a..3596842c 100644
--- a/hikari/applications.py
+++ b/hikari/applications.py
@@ -59,7 +59,6 @@
 if typing.TYPE_CHECKING:
     import datetime
 
-    from hikari import channels
     from hikari import files
     from hikari import permissions as permissions_
     from hikari import traits
@@ -347,10 +346,6 @@ def flags(self) -> users.UserFlag:
     def id(self) -> snowflakes.Snowflake:
         return self.user.id
 
-    @id.setter
-    def id(self, value: snowflakes.Snowflake) -> typing.NoReturn:
-        raise TypeError("Cannot mutate the ID of a member")
-
     @property
     def is_bot(self) -> bool:
         return self.user.is_bot
@@ -367,9 +362,6 @@ def mention(self) -> str:
     def username(self) -> str:
         return self.user.username
 
-    async def fetch_dm_channel(self) -> channels.DMChannel:
-        return await self.user.fetch_dm_channel()
-
     def __str__(self) -> str:
         return str(self.user)
 
diff --git a/hikari/channels.py b/hikari/channels.py
index 4a8dfc65..d0c748fd 100644
--- a/hikari/channels.py
+++ b/hikari/channels.py
@@ -734,12 +734,8 @@ def shard_id(self) -> typing.Optional[int]:
 
         This may be `builtins.None` if the shard count is not known.
         """
-        try:
-            shard_count = getattr(self.app, "shard_count")
-            assert isinstance(shard_count, int), f"shard_count attr was expected to be int, but got {shard_count}"
-            return snowflakes.calculate_shard_id(shard_count, self.guild_id)
-        except (TypeError, AttributeError, NameError):
-            pass
+        if isinstance(self.app, traits.ShardAware):
+            return snowflakes.calculate_shard_id(self.app, self.guild_id)
 
         return None
 
diff --git a/hikari/config.py b/hikari/config.py
index 25ab27a8..c57d3269 100644
--- a/hikari/config.py
+++ b/hikari/config.py
@@ -428,7 +428,21 @@ class CacheComponents(enums.Flag):
     MESSAGES = 1 << 8
     """Enables the messages cache."""
 
-    ALL = GUILDS | GUILD_CHANNELS | MEMBERS | ROLES | INVITES | EMOJIS | PRESENCES | VOICE_STATES | MESSAGES
+    DM_CHANNEL_IDS = 1 << 10
+    """Enables the DM channel IDs cache."""
+
+    ALL = (
+        GUILDS
+        | GUILD_CHANNELS
+        | MEMBERS
+        | ROLES
+        | INVITES
+        | EMOJIS
+        | PRESENCES
+        | VOICE_STATES
+        | MESSAGES
+        | DM_CHANNEL_IDS
+    )
     """Fully enables the cache."""
 
 
diff --git a/hikari/embeds.py b/hikari/embeds.py
index 1c6f587d..c8a6a3ee 100644
--- a/hikari/embeds.py
+++ b/hikari/embeds.py
@@ -733,13 +733,8 @@ def set_author(
         if name is None and url is None and icon is None:
             self._author = None
         else:
-            self._author = EmbedAuthor()
-            self._author.name = name
-            self._author.url = url
-            if icon is not None:
-                self._author.icon = EmbedResourceWithProxy(resource=files.ensure_resource(icon))
-            else:
-                self._author.icon = None
+            real_icon = EmbedResourceWithProxy(resource=files.ensure_resource(icon)) if icon is not None else None
+            self._author = EmbedAuthor(name=name, url=url, icon=real_icon)
         return self
 
     def set_footer(self, *, text: typing.Optional[str], icon: typing.Optional[files.Resourceish] = None) -> Embed:
@@ -787,12 +782,8 @@ def set_footer(self, *, text: typing.Optional[str], icon: typing.Optional[files.
 
             self._footer = None
         else:
-            self._footer = EmbedFooter()
-            self._footer.text = text
-            if icon is not None:
-                self._footer.icon = EmbedResourceWithProxy(resource=files.ensure_resource(icon))
-            else:
-                self._footer.icon = None
+            real_icon = EmbedResourceWithProxy(resource=files.ensure_resource(icon)) if icon is not None else None
+            self._footer = EmbedFooter(icon=real_icon, text=text)
         return self
 
     def set_image(self, image: typing.Optional[files.Resourceish] = None, /) -> Embed:
diff --git a/hikari/guilds.py b/hikari/guilds.py
index 6a839647..c5c84638 100644
--- a/hikari/guilds.py
+++ b/hikari/guilds.py
@@ -383,10 +383,6 @@ def flags(self) -> users.UserFlag:
     def id(self) -> snowflakes.Snowflake:
         return self.user.id
 
-    @id.setter
-    def id(self, value: snowflakes.Snowflake) -> None:
-        raise TypeError("Cannot mutate the ID of a member")
-
     @property
     def is_bot(self) -> bool:
         return self.user.is_bot
@@ -510,9 +506,6 @@ async def fetch_self(self) -> Member:
         """
         return await self.user.app.rest.fetch_member(self.guild_id, self.user.id)
 
-    async def fetch_dm_channel(self) -> channels_.DMChannel:
-        return await self.user.fetch_dm_channel()
-
     async def ban(
         self,
         *,
diff --git a/hikari/impl/bot.py b/hikari/impl/bot.py
index 7a67133e..ab839623 100644
--- a/hikari/impl/bot.py
+++ b/hikari/impl/bot.py
@@ -213,7 +213,6 @@ class BotApp(traits.BotAware):
     __slots__: typing.Sequence[str] = (
         "_cache",
         "_closing_event",
-        "_closed",
         "_entity_factory",
         "_event_manager",
         "_event_factory",
@@ -250,8 +249,7 @@ def __init__(
         self.print_banner(banner, allow_color, force_color)
 
         # Settings and state
-        self._closing_event = asyncio.Event()
-        self._closed = False
+        self._closing_event: typing.Optional[asyncio.Event] = None
         self._is_alive = False
         self._executor = executor
         self._http_settings = http_settings if http_settings is not None else config.HTTPSettings()
@@ -277,6 +275,7 @@ def __init__(
 
         # RESTful API.
         self._rest = rest_impl.RESTClientImpl(
+            cache=self._cache,
             entity_factory=self._entity_factory,
             executor=self._executor,
             http_settings=self._http_settings,
@@ -359,15 +358,14 @@ def _check_if_alive(self) -> None:
 
     async def close(self, force: bool = True) -> None:
         """Kill the application by shutting all components down."""
-        if not self._closing_event.is_set():
+        if self._closing_event and not self._closing_event.is_set():
             _LOGGER.debug("bot requested to shutdown [force:%s]", force)
+            self._closing_event.set()
 
-        self._closing_event.set()
-
-        if self._closed or not force:
+        if not self._closing_event or not force:  # If closing event is None then this is already closing.
             return
 
-        self._closed = True
+        self._closing_event = None
 
         async def handle(name: str, awaitable: typing.Awaitable[typing.Any]) -> None:
             future = asyncio.ensure_future(awaitable)
@@ -420,7 +418,7 @@ async def join(self, until_close: bool = True) -> None:
         self._check_if_alive()
 
         awaitables: typing.List[typing.Awaitable[typing.Any]] = [s.join() for s in self._shards.values()]
-        if until_close:
+        if until_close and self._closing_event:  # If closing event is None then this is already closing.
             awaitables.append(self._closing_event.wait())
 
         await aio.first_completed(*awaitables)
@@ -779,7 +777,7 @@ async def start(
             raise errors.GatewayError("Attempted to start more sessions than were allowed in the given time-window")
 
         self._is_alive = True
-        self._closing_event.clear()
+        self._closing_event = asyncio.Event()
         _LOGGER.info(
             "you can start %s session%s before the next window which starts at %s; planning to start %s session%s... ",
             requirements.session_start_limit.remaining,
@@ -843,6 +841,7 @@ async def start(
                         shard_id=candidate_shard_id,
                         shard_count=shard_count,
                         url=requirements.url,
+                        closing_event=self._closing_event,
                     )
                     for candidate_shard_id in window
                     if candidate_shard_id in shard_ids
@@ -954,6 +953,7 @@ async def _start_one_shard(
         shard_id: int,
         shard_count: int,
         url: str,
+        closing_event: asyncio.Event,
     ) -> shard_impl.GatewayShardImpl:
         new_shard = shard_impl.GatewayShardImpl(
             http_settings=self._http_settings,
@@ -974,7 +974,7 @@ async def _start_one_shard(
         self._shards[shard_id] = new_shard
 
         start = time.monotonic()
-        await aio.first_completed(new_shard.start(), self._closing_event.wait())
+        await aio.first_completed(new_shard.start(), closing_event.wait())
         end = time.monotonic()
 
         if new_shard.is_alive:
diff --git a/hikari/impl/cache.py b/hikari/impl/cache.py
index 4fe5ad87..451b98d0 100644
--- a/hikari/impl/cache.py
+++ b/hikari/impl/cache.py
@@ -67,6 +67,7 @@ class CacheImpl(cache.MutableCache):
 
     __slots__: typing.Sequence[str] = (
         "_app",
+        "_dm_channel_entries",
         "_emoji_entries",
         "_guild_channel_entries",
         "_guild_entries",
@@ -81,6 +82,7 @@ class CacheImpl(cache.MutableCache):
         "_settings",
     )
 
+    _dm_channel_entries: collections.ExtendedMutableMapping[snowflakes.Snowflake, snowflakes.Snowflake]
     # For the sake of keeping things clean, the annotations are being kept separate from the assignment here.
     _me: typing.Optional[users.OwnUser]
     _emoji_entries: collections.ExtendedMutableMapping[snowflakes.Snowflake, cache_utility.KnownCustomEmojiData]
@@ -112,6 +114,7 @@ def settings(self) -> config.CacheSettings:
         return self._settings
 
     def _create_cache(self) -> None:
+        self._dm_channel_entries = collections.FreezableDict()
         self._emoji_entries = collections.FreezableDict()
         self._guild_channel_entries = collections.FreezableDict()
         self._guild_entries = collections.FreezableDict()
@@ -139,6 +142,34 @@ def clear(self) -> None:
 
         self._create_cache()
 
+    def clear_dm_channel_ids(self) -> cache.CacheView[snowflakes.Snowflake, snowflakes.Snowflake]:
+        result = self._dm_channel_entries
+        self._dm_channel_entries = collections.FreezableDict()
+        return cache_utility.CacheMappingView(result)
+
+    def delete_dm_channel_id(
+        self, user: snowflakes.SnowflakeishOr[users.PartialUser], /
+    ) -> typing.Optional[snowflakes.Snowflake]:
+        return self._dm_channel_entries.pop(snowflakes.Snowflake(user), None)
+
+    def get_dm_channel_id(
+        self, user: snowflakes.SnowflakeishOr[users.PartialUser], /
+    ) -> typing.Optional[snowflakes.Snowflake]:
+        return self._dm_channel_entries.get(snowflakes.Snowflake(user))
+
+    def get_dm_channel_ids_view(self) -> cache.CacheView[snowflakes.Snowflake, snowflakes.Snowflake]:
+        return cache_utility.CacheMappingView(self._dm_channel_entries.freeze())
+
+    def set_dm_channel_id(
+        self,
+        user: snowflakes.SnowflakeishOr[users.PartialUser],
+        channel: snowflakes.SnowflakeishOr[channels.PartialChannel],
+        /,
+    ) -> None:
+        user = snowflakes.Snowflake(user)
+        if self._is_cache_enabled_for(config.CacheComponents.EMOJIS) and user in self._user_entries:
+            self._dm_channel_entries[user] = snowflakes.Snowflake(channel)
+
     def _build_emoji(
         self,
         emoji_data: cache_utility.KnownCustomEmojiData,
@@ -1192,6 +1223,7 @@ def _garbage_collect_user(
 
         if self._can_remove_user(user) and user.object.id in self._user_entries:
             del self._user_entries[user.object.id]
+            self._dm_channel_entries.pop(user.object.id, None)
 
     def get_user(self, user: snowflakes.SnowflakeishOr[users.PartialUser], /) -> typing.Optional[users.User]:
         user = self._user_entries.get(snowflakes.Snowflake(user))
diff --git a/hikari/impl/rest.py b/hikari/impl/rest.py
index 85eeb745..a41e4ddf 100644
--- a/hikari/impl/rest.py
+++ b/hikari/impl/rest.py
@@ -91,6 +91,7 @@
     from hikari import templates
     from hikari import voices
     from hikari import webhooks
+    from hikari.api import cache as cache_api
     from hikari.api import entity_factory as entity_factory_
 
 _LOGGER: typing.Final[logging.Logger] = logging.getLogger("hikari.rest")
@@ -342,6 +343,7 @@ def acquire(
         entity_factory = entity_factory_impl.EntityFactoryImpl(provider)
 
         rest_client = RESTClientImpl(
+            cache=None,
             entity_factory=entity_factory,
             executor=self._executor,
             http_settings=self._http_settings,
@@ -390,6 +392,7 @@ class RESTClientImpl(rest_api.RESTClient):
     __slots__: typing.Sequence[str] = (
         "buckets",
         "global_rate_limit",
+        "_cache",
         "_client_session",
         "_closed_event",
         "_tcp_connector",
@@ -414,6 +417,7 @@ class _RetryRequest(RuntimeError):
     def __init__(
         self,
         *,
+        cache: typing.Optional[cache_api.MutableCache],
         entity_factory: entity_factory_.EntityFactory,
         executor: typing.Optional[concurrent.futures.Executor],
         http_settings: config.HTTPSettings,
@@ -427,6 +431,7 @@ def __init__(
         # We've been told in DAPI that this is per token.
         self.global_rate_limit = rate_limits.ManualRateLimiter()
 
+        self._cache = cache
         self._client_session: typing.Optional[aiohttp.ClientSession] = None
         self._tcp_connector: typing.Optional[aiohttp.TCPConnector] = None
         self._closed_event = asyncio.Event()
@@ -791,7 +796,12 @@ async def fetch_channel(
         route = routes.GET_CHANNEL.compile(channel=channel)
         response = await self._request(route)
         assert isinstance(response, dict)
-        return self._entity_factory.deserialize_channel(response)
+        result = self._entity_factory.deserialize_channel(response)
+
+        if self._cache and isinstance(result, channels_.DMChannel):
+            self._cache.set_dm_channel_id(result.recipient.id, result.id)
+
+        return result
 
     async def edit_channel(
         self,
@@ -1827,7 +1837,12 @@ async def create_dm_channel(self, user: snowflakes.SnowflakeishOr[users.PartialU
         body.put_snowflake("recipient_id", user)
         response = await self._request(route, json=body)
         assert isinstance(response, dict)
-        return self._entity_factory.deserialize_dm(response)
+        channel = self._entity_factory.deserialize_dm(response)
+
+        if self._cache:
+            self._cache.set_dm_channel_id(user, channel.id)
+
+        return channel
 
     async def fetch_application(self) -> applications.Application:
         route = routes.GET_MY_APPLICATION.compile()
diff --git a/hikari/internal/cache.py b/hikari/internal/cache.py
index 7e6b0156..fd5f4ea3 100644
--- a/hikari/internal/cache.py
+++ b/hikari/internal/cache.py
@@ -983,7 +983,7 @@ def copy_guild_channel(channel: ChannelT) -> ChannelT:
 
 
 class Cache3DMappingView(CacheMappingView[snowflakes.Snowflake, cache.CacheView[KeyT, ValueT]]):
-    """A special case of the Mapping View which avoids copying the already immutable views contained within it."""
+    """A special case of the Mapping View which avoids copying the immutable values contained within it."""
 
     __slots__: typing.Sequence[str] = ()
 
diff --git a/hikari/snowflakes.py b/hikari/snowflakes.py
index 19d90c1b..e26bc928 100644
--- a/hikari/snowflakes.py
+++ b/hikari/snowflakes.py
@@ -124,11 +124,6 @@ def id(self) -> Snowflake:
             The snowflake ID of this object.
         """
 
-    # TODO: make immutable interface, as this is a major risk to consistent hash codes.
-    @id.setter
-    def id(self, value: Snowflake) -> None:
-        """Set the ID on this entity."""
-
     @property
     def created_at(self) -> datetime.datetime:
         """When the object was created."""
diff --git a/hikari/users.py b/hikari/users.py
index af655589..5f8b65c2 100644
--- a/hikari/users.py
+++ b/hikari/users.py
@@ -32,6 +32,7 @@
 import attr
 
 from hikari import snowflakes
+from hikari import traits
 from hikari import undefined
 from hikari import urls
 from hikari.internal import attr_extensions
@@ -44,7 +45,6 @@
     from hikari import files
     from hikari import guilds
     from hikari import messages
-    from hikari import traits
 
 
 @typing.final
@@ -180,7 +180,6 @@ def mention(self) -> str:
             The mention string to use.
         """
 
-    @abc.abstractmethod
     async def fetch_dm_channel(self) -> channels.DMChannel:
         """Fetch the DM channel for this user.
 
@@ -209,6 +208,7 @@ async def fetch_dm_channel(self) -> channels.DMChannel:
         hikari.errors.InternalServerError
             If an internal error occurs on Discord while handling the request.
         """
+        return await self.app.rest.create_dm_channel(self.id)
 
     async def fetch_self(self) -> User:
         """Get this user's up-to-date object by performing an API call.
@@ -390,10 +390,15 @@ async def send(
         hikari.errors.InternalServerError
             If an internal error occurs on Discord while handling the request.
         """  # noqa: E501 - Line too long
-        channel = await self.fetch_dm_channel()
+        channel_id = None
+        if isinstance(self.app, traits.CacheAware):
+            channel_id = self.app.cache.get_dm_channel_id(self.id)
+
+        if channel_id is None:
+            channel_id = (await self.fetch_dm_channel()).id
 
         return await self.app.rest.create_message(
-            channel=channel.id,
+            channel=channel_id,
             content=content,
             embed=embed,
             attachment=attachment,
@@ -573,16 +578,6 @@ class PartialUserImpl(PartialUser):
     flags: undefined.UndefinedOr[UserFlag] = attr.field(eq=False, hash=False)
     """Public flags for this user."""
 
-    _dm_channel: typing.Optional[channels.DMChannel] = attr.field(
-        eq=False, hash=False, init=False, repr=False, default=None
-    )
-
-    async def fetch_dm_channel(self) -> channels.DMChannel:
-        if self._dm_channel is None:
-            self._dm_channel = await self.app.rest.create_dm_channel(self.id)
-
-        return self._dm_channel
-
     @property
     def mention(self) -> str:
         """Return a raw mention string for the given user.


Test patch:
diff --git a/tests/hikari/impl/test_bot.py b/tests/hikari/impl/test_bot.py
index 23bf67c1..3eec2512 100644
--- a/tests/hikari/impl/test_bot.py
+++ b/tests/hikari/impl/test_bot.py
@@ -162,6 +162,7 @@ def test_init(self):
         voice.assert_called_once_with(bot)
         assert bot._rest is rest.return_value
         rest.assert_called_once_with(
+            cache=bot._cache,
             entity_factory=bot._entity_factory,
             executor=executor,
             http_settings=bot._http_settings,
@@ -278,26 +279,31 @@ def test_check_if_alive(self, bot):
 
     @pytest.mark.asyncio
     async def test_close_when_not_force(self, bot, event_manager):
-        bot._closing_event = mock.Mock(is_set=mock.Mock(return_value=True))
-        bot._closed = False
+        bot._closing_event = mock.Mock(is_set=mock.Mock(return_value=False))
         bot._is_alive = True
 
         await bot.close(force=False)
 
         bot._closing_event.set.assert_called_once_with()
-        assert bot._closed is False
         assert bot._is_alive is True
 
     @pytest.mark.asyncio
-    async def test_close_when_already_closed(self, bot, event_manager):
+    async def test_close_when_not_force_and_closing_event_set(self, bot, event_manager):
         bot._closing_event = mock.Mock(is_set=mock.Mock(return_value=True))
-        bot._closed = True
+        bot._is_alive = True
+
+        await bot.close(force=False)
+
+        bot._closing_event.set.assert_not_called()
+        assert bot._is_alive is True
+
+    @pytest.mark.asyncio
+    async def test_close_when_already_closed(self, bot, event_manager):
+        bot._closing_event = None
         bot._is_alive = True
 
         await bot.close(force=True)
 
-        bot._closing_event.set.assert_called_once_with()
-        assert bot._closed is True
         assert bot._is_alive is True
 
     @pytest.mark.asyncio
@@ -334,8 +340,7 @@ def assert_awaited_once(self):
         event_manager.dispatch = mock.AsyncMock()
         rest.close = AwaitMock()
         voice.close = AwaitMock()
-        bot._closing_event = mock.Mock(is_set=mock.Mock(return_value=False))
-        bot._closed = False
+        bot._closing_event = closing_event = mock.Mock(is_set=mock.Mock(return_value=False))
         bot._is_alive = True
         error = RuntimeError()
         shard0 = mock.Mock(id=0, close=AwaitMock())
@@ -347,8 +352,8 @@ def assert_awaited_once(self):
             await bot.close(force=True)
 
         # Closing event and args
-        bot._closing_event.set.assert_called_once_with()
-        assert bot._closed is True
+        closing_event.set.assert_called_once_with()
+        assert bot._closing_event is None
         assert bot._is_alive is False
 
         # Closing components
@@ -779,7 +784,7 @@ async def test_start_one_shard(self, bot):
         activity = object()
         status = object()
         bot._shards = {}
-        bot._closing_event = mock.Mock()
+        closing_event = mock.Mock()
         shard = mock.Mock()
         shard_obj = shard.return_value
         shard_obj.is_alive = True
@@ -795,6 +800,7 @@ async def test_start_one_shard(self, bot):
                     shard_id=1,
                     shard_count=3,
                     url="https://some.website",
+                    closing_event=closing_event,
                 )
 
                 assert returned is shard_obj
@@ -816,7 +822,7 @@ async def test_start_one_shard(self, bot):
             url="https://some.website",
         )
         assert bot._shards == {1: shard_obj}
-        first_completed.assert_awaited_once_with(shard_obj.start.return_value, bot._closing_event.wait.return_value)
+        first_completed.assert_awaited_once_with(shard_obj.start.return_value, closing_event.wait.return_value)
 
     @pytest.mark.asyncio
     async def test_start_one_shard_when_not_alive(self, bot):
@@ -839,6 +845,7 @@ async def test_start_one_shard_when_not_alive(self, bot):
                         shard_id=1,
                         shard_count=3,
                         url="https://some.website",
+                        closing_event=bot._closing_event,
                     )
 
     @pytest.mark.parametrize("activity", [undefined.UNDEFINED, None])
diff --git a/tests/hikari/impl/test_cache.py b/tests/hikari/impl/test_cache.py
index 02809ab6..c59ca1cf 100644
--- a/tests/hikari/impl/test_cache.py
+++ b/tests/hikari/impl/test_cache.py
@@ -83,6 +83,64 @@ def test_clear(self, cache_impl):
 
         cache_impl._create_cache.assert_called_once_with()
 
+    def test_clear_dm_channel_ids(self, cache_impl):
+        cache_impl._dm_channel_entries = collections.FreezableDict({123: 5423, 23123: 54123123})
+
+        result = cache_impl.clear_dm_channel_ids()
+
+        assert result == {123: 5423, 23123: 54123123}
+        assert cache_impl._dm_channel_entries == {}
+
+    def test_delete_dm_channel_id(self, cache_impl):
+        cache_impl._dm_channel_entries = collections.FreezableDict({54123: 2123123, 5434: 1234})
+
+        result = cache_impl.delete_dm_channel_id(54123)
+
+        assert result == 2123123
+        assert cache_impl._dm_channel_entries == {5434: 1234}
+
+    def test_delete_dm_channel_id_for_unknown_user(self, cache_impl):
+        cache_impl._dm_channel_entries = collections.FreezableDict({54123: 2123123, 5434: 1234})
+
+        result = cache_impl.delete_dm_channel_id(65234123123)
+
+        assert result is None
+        assert cache_impl._dm_channel_entries == {54123: 2123123, 5434: 1234}
+
+    def test_get_dm_channel_id(self, cache_impl):
+        cache_impl._dm_channel_entries = collections.FreezableDict({24123123: 453123, 5423: 123, 653: 1223})
+
+        assert cache_impl.get_dm_channel_id(5423) == 123
+
+    def test_get_dm_channel_id_for_unknown_user(self, cache_impl):
+        cache_impl._dm_channel_entries = collections.FreezableDict({24123123: 453123, 5423: 123, 653: 1223})
+
+        assert cache_impl.get_dm_channel_id(65656565) is None
+
+    def test_get_dm_channel_ids_view(self, cache_impl):
+        cache_impl._dm_channel_entries = collections.FreezableDict({222: 333, 643: 213, 54234: 1231321})
+
+        assert cache_impl.get_dm_channel_ids_view() == {222: 333, 643: 213, 54234: 1231321}
+
+    def test_set_dm_channel_id(self, cache_impl):
+        cache_impl._user_entries = collections.FreezableDict({43123123: object()})
+
+        cache_impl.set_dm_channel_id(StubModel(43123123), StubModel(12222))
+
+        assert cache_impl._dm_channel_entries == {43123123: 12222}
+
+    def test_set_dm_channel_id_when_user_not_known(self, cache_impl):
+        cache_impl.set_dm_channel_id(StubModel(43123123), StubModel(12222))
+
+        assert cache_impl._dm_channel_entries == {}
+
+    def test_set_dm_channel_id_when_not_enabled(self, cache_impl):
+        cache_impl._user_entries = collections.FreezableDict({43123123: object()})
+        cache_impl._settings.components = 0
+        cache_impl.set_dm_channel_id(StubModel(43123123), StubModel(12222))
+
+        assert cache_impl._dm_channel_entries == {}
+
     def test__build_emoji(self, cache_impl):
         mock_user = mock.MagicMock(users.User)
         emoji_data = cache_utilities.KnownCustomEmojiData(
@@ -1938,7 +1996,23 @@ def test__garbage_collect_user_for_known_unreferenced_user(self, cache_impl):
 
         cache_impl._garbage_collect_user(mock_user, decrement=1)
 
-        assert dict(cache_impl._user_entries) == {snowflakes.Snowflake(645234): mock_other_user}
+        assert cache_impl._user_entries == {snowflakes.Snowflake(645234): mock_other_user}
+
+    def test__garbage_collect_user_for_known_unreferenced_user_removes_cached_dm_channelo(self, cache_impl):
+        mock_user = cache_utilities.RefCell(mock.Mock(id=snowflakes.Snowflake(21231234)), ref_count=1)
+        cache_impl._dm_channel_entries = collections.FreezableDict({21231234: 123123123})
+        mock_other_user = mock.Mock(cache_utilities.RefCell, ref_count=1)
+        cache_impl._user_entries = collections.FreezableDict(
+            {
+                snowflakes.Snowflake(21231234): mock_user,
+                snowflakes.Snowflake(645234): mock_other_user,
+            }
+        )
+
+        cache_impl._garbage_collect_user(mock_user, decrement=1)
+
+        assert cache_impl._user_entries == {snowflakes.Snowflake(645234): mock_other_user}
+        assert cache_impl._dm_channel_entries == {}
 
     def test_garbage_collect_user_for_referenced_user(self, cache_impl):
         mock_user = cache_utilities.RefCell(mock.Mock(id=snowflakes.Snowflake(21231234)), ref_count=2)
diff --git a/tests/hikari/impl/test_rest.py b/tests/hikari/impl/test_rest.py
index 3c755b94..1f50c1b3 100644
--- a/tests/hikari/impl/test_rest.py
+++ b/tests/hikari/impl/test_rest.py
@@ -287,6 +287,7 @@ def test_acquire(self, rest_app):
             rest_app.acquire(token="token", token_type="Type")
 
         mock_client.assert_called_once_with(
+            cache=None,
             entity_factory=_entity_factory(),
             executor=rest_app._executor,
             http_settings=rest_app._http_settings,
@@ -309,8 +310,14 @@ def rest_client_class():
 
 
 @pytest.fixture()
-def rest_client(rest_client_class):
+def mock_cache():
+    return mock.Mock()
+
+
+@pytest.fixture()
+def rest_client(rest_client_class, mock_cache):
     obj = rest_client_class(
+        cache=mock_cache,
         http_settings=mock.Mock(spec=config.HTTPSettings),
         max_rate_limit=float("inf"),
         proxy_settings=mock.Mock(spec=config.ProxySettings),
@@ -376,6 +383,7 @@ class TestRESTClientImpl:
     def test__init__passes_max_rate_limit(self):
         with mock.patch.object(buckets, "RESTBucketManager") as bucket:
             rest.RESTClientImpl(
+                cache=None,
                 http_settings=mock.Mock(),
                 max_rate_limit=float("inf"),
                 proxy_settings=mock.Mock(),
@@ -391,6 +399,7 @@ def test__init__passes_max_rate_limit(self):
     def test__init__when_token_strategy_passed_with_token_type(self):
         with pytest.raises(ValueError, match="Token type should be handled by the token strategy"):
             rest.RESTClientImpl(
+                cache=None,
                 http_settings=mock.Mock(),
                 max_rate_limit=float("inf"),
                 proxy_settings=mock.Mock(),
@@ -403,6 +412,7 @@ def test__init__when_token_strategy_passed_with_token_type(self):
 
     def test__init__when_token_is_None_sets_token_to_None(self):
         obj = rest.RESTClientImpl(
+            cache=None,
             http_settings=mock.Mock(),
             max_rate_limit=float("inf"),
             proxy_settings=mock.Mock(),
@@ -416,6 +426,7 @@ def test__init__when_token_is_None_sets_token_to_None(self):
 
     def test__init__when_token_and_token_type_is_not_None_generates_token_with_type(self):
         obj = rest.RESTClientImpl(
+            cache=None,
             http_settings=mock.Mock(),
             max_rate_limit=float("inf"),
             proxy_settings=mock.Mock(),
@@ -429,6 +440,7 @@ def test__init__when_token_and_token_type_is_not_None_generates_token_with_type(
 
     def test__init__when_rest_url_is_None_generates_url_using_default_url(self):
         obj = rest.RESTClientImpl(
+            cache=None,
             http_settings=mock.Mock(),
             max_rate_limit=float("inf"),
             proxy_settings=mock.Mock(),
@@ -442,6 +454,7 @@ def test__init__when_rest_url_is_None_generates_url_using_default_url(self):
 
     def test__init__when_rest_url_is_not_None_generates_url_using_given_url(self):
         obj = rest.RESTClientImpl(
+            cache=None,
             http_settings=mock.Mock(),
             max_rate_limit=float("inf"),
             proxy_settings=mock.Mock(),
@@ -1285,6 +1298,29 @@ async def test_fetch_channel(self, rest_client):
         rest_client._request.assert_awaited_once_with(expected_route)
         rest_client._entity_factory.deserialize_channel.assert_called_once_with(rest_client._request.return_value)
 
+    async def test_fetch_channel_with_dm_channel_when_cacheful(self, rest_client, mock_cache):
+        expected_route = routes.GET_CHANNEL.compile(channel=123)
+        mock_object = mock.Mock(spec=channels.DMChannel, type=channels.ChannelType.DM)
+        rest_client._entity_factory.deserialize_channel = mock.Mock(return_value=mock_object)
+        rest_client._request = mock.AsyncMock(return_value={"payload": "NO"})
+
+        assert await rest_client.fetch_channel(StubModel(123)) == mock_object
+        rest_client._request.assert_awaited_once_with(expected_route)
+        rest_client._entity_factory.deserialize_channel.assert_called_once_with(rest_client._request.return_value)
+        mock_cache.set_dm_channel_id.assert_called_once_with(mock_object.recipient.id, mock_object.id)
+
+    async def test_fetch_channel_with_dm_channel_when_cacheless(self, rest_client, mock_cache):
+        expected_route = routes.GET_CHANNEL.compile(channel=123)
+        mock_object = mock.Mock(spec=channels.DMChannel, type=channels.ChannelType.DM)
+        rest_client._cache = None
+        rest_client._entity_factory.deserialize_channel = mock.Mock(return_value=mock_object)
+        rest_client._request = mock.AsyncMock(return_value={"payload": "NO"})
+
+        assert await rest_client.fetch_channel(StubModel(123)) == mock_object
+        rest_client._request.assert_awaited_once_with(expected_route)
+        rest_client._entity_factory.deserialize_channel.assert_called_once_with(rest_client._request.return_value)
+        mock_cache.set_dm_channel_id.assert_not_called()
+
     async def test_edit_channel(self, rest_client):
         expected_route = routes.PATCH_CHANNEL.compile(channel=123)
         mock_object = mock.Mock()
@@ -2013,16 +2049,31 @@ async def test_leave_guild(self, rest_client):
         await rest_client.leave_guild(StubModel(123))
         rest_client._request.assert_awaited_once_with(expected_route)
 
-    async def test_create_dm_channel(self, rest_client):
-        dm_channel = StubModel(123)
+    async def test_create_dm_channel(self, rest_client, mock_cache):
+        dm_channel = StubModel(43234)
+        user = StubModel(123)
         expected_route = routes.POST_MY_CHANNELS.compile()
         expected_json = {"recipient_id": "123"}
-        rest_client._request = mock.AsyncMock(return_value={"id": "123"})
+        rest_client._request = mock.AsyncMock(return_value={"id": "43234"})
+        rest_client._entity_factory.deserialize_dm = mock.Mock(return_value=dm_channel)
+
+        assert await rest_client.create_dm_channel(user) == dm_channel
+        rest_client._request.assert_awaited_once_with(expected_route, json=expected_json)
+        rest_client._entity_factory.deserialize_dm.assert_called_once_with({"id": "43234"})
+        mock_cache.set_dm_channel_id.assert_called_once_with(user, dm_channel.id)
+
+    async def test_create_dm_channel_when_cacheless(self, rest_client, mock_cache):
+        rest_client._cache = None
+        dm_channel = StubModel(43234)
+        expected_route = routes.POST_MY_CHANNELS.compile()
+        expected_json = {"recipient_id": "123"}
+        rest_client._request = mock.AsyncMock(return_value={"id": "43234"})
         rest_client._entity_factory.deserialize_dm = mock.Mock(return_value=dm_channel)
 
         assert await rest_client.create_dm_channel(StubModel(123)) == dm_channel
         rest_client._request.assert_awaited_once_with(expected_route, json=expected_json)
-        rest_client._entity_factory.deserialize_dm.assert_called_once_with({"id": "123"})
+        rest_client._entity_factory.deserialize_dm.assert_called_once_with({"id": "43234"})
+        mock_cache.set_dm_channel_id.assert_not_called()
 
     async def test_fetch_application(self, rest_client):
         application = StubModel(123)
diff --git a/tests/hikari/test_applications.py b/tests/hikari/test_applications.py
index 327409f3..5368fbc3 100644
--- a/tests/hikari/test_applications.py
+++ b/tests/hikari/test_applications.py
@@ -57,10 +57,6 @@ def test_flags_property(self, model):
     def test_id_property(self, model):
         assert model.id is model.user.id
 
-    def test_id_setter(self, model):
-        with pytest.raises(TypeError, match="Cannot mutate the ID of a member"):
-            model.id = 42
-
     def test_is_bot_property(self, model):
         assert model.is_bot is model.user.is_bot
 
@@ -73,14 +69,6 @@ def test_mention_property(self, model):
     def test_username_property(self, model):
         assert model.username is model.user.username
 
-    @pytest.mark.asyncio
-    async def test_fetch_dm_channel(self, model):
-        model.user.fetch_dm_channel = mock.AsyncMock()
-
-        result = await model.fetch_dm_channel()
-        assert result is model.user.fetch_dm_channel.return_value
-        model.user.fetch_dm_channel.assert_awaited_once()
-
     def test_str_operator(self):
         mock_team_member = mock.Mock(
             applications.TeamMember, user=mock.Mock(users.User, __str__=mock.Mock(return_value="mario#1234"))
diff --git a/tests/hikari/test_channels.py b/tests/hikari/test_channels.py
index 36599200..8731f224 100644
--- a/tests/hikari/test_channels.py
+++ b/tests/hikari/test_channels.py
@@ -269,15 +269,8 @@ def model(self, mock_app):
             parent_id=None,
         )
 
-    @pytest.mark.parametrize("error", [TypeError, AttributeError, NameError])
-    def test_shard_id_property_when_guild_id_error_raised(self, model, error):
-        class BrokenApp:
-            def __getattr__(self, name):
-                if name == "shard_count":
-                    raise error
-                return mock.Mock()
-
-        model.app = BrokenApp()
+    def test_shard_id_property_when_not_shard_aware(self, model):
+        model.app = None
 
         assert model.shard_id is None
 
diff --git a/tests/hikari/test_guilds.py b/tests/hikari/test_guilds.py
index c462a3c6..37f75218 100644
--- a/tests/hikari/test_guilds.py
+++ b/tests/hikari/test_guilds.py
@@ -178,10 +178,6 @@ def test_app_property(self, model, mock_user):
     def test_id_property(self, model, mock_user):
         assert model.id is mock_user.id
 
-    def test_id_setter_property(self, model):
-        with pytest.raises(TypeError):
-            model.id = 456
-
     def test_username_property(self, model, mock_user):
         assert model.username is mock_user.username
 
@@ -208,13 +204,6 @@ def test_make_avatar_url(self, model, mock_user):
         mock_user.make_avatar_url.assert_called_once_with(ext="png", size=4096)
         assert result is mock_user.make_avatar_url.return_value
 
-    @pytest.mark.asyncio
-    async def test_fetch_dm_channel(self, model, mock_user):
-        mock_user.fetch_dm_channel = mock.AsyncMock()
-        result = await model.fetch_dm_channel()
-        mock_user.fetch_dm_channel.assert_awaited_once_with()
-        assert result is mock_user.fetch_dm_channel.return_value
-
     @pytest.mark.asyncio
     async def test_fetch_self(self, model):
         model.user.app.rest.fetch_member = mock.AsyncMock()
diff --git a/tests/hikari/test_users.py b/tests/hikari/test_users.py
index f04e0810..6ca1776e 100644
--- a/tests/hikari/test_users.py
+++ b/tests/hikari/test_users.py
@@ -23,6 +23,7 @@
 import pytest
 
 from hikari import snowflakes
+from hikari import traits
 from hikari import undefined
 from hikari import urls
 from hikari import users
@@ -45,21 +46,113 @@ async def test_fetch_self(self, obj):
         obj.app.rest.fetch_user.assert_awaited_once_with(user=123)
 
     @pytest.mark.asyncio()
-    async def test_send(self, obj):
+    async def test_send_uses_cached_id(self, obj):
+        obj.id = 4123123
         embed = object()
         attachment = object()
         attachments = [object(), object()]
         user_mentions = [object(), object()]
         role_mentions = [object(), object()]
-        mock_channel = mock.Mock(id=456)
-        mock_message = object()
         reply = object()
         mentions_reply = object()
 
-        obj.app = mock.Mock()
-        obj.fetch_dm_channel = mock.AsyncMock(return_value=mock_channel)
-        obj.app.rest.create_message = mock.AsyncMock(return_value=mock_message)
-        obj._dm_channel = None
+        obj.app = mock.Mock(spec=traits.CacheAware, rest=mock.AsyncMock())
+        obj.fetch_dm_channel = mock.AsyncMock()
+
+        returned = await obj.send(
+            content="test",
+            embed=embed,
+            attachment=attachment,
+            attachments=attachments,
+            nonce="nonce",
+            tts=True,
+            reply=reply,
+            mentions_everyone=False,
+            user_mentions=user_mentions,
+            role_mentions=role_mentions,
+            mentions_reply=mentions_reply,
+        )
+
+        assert returned is obj.app.rest.create_message.return_value
+
+        obj.app.cache.get_dm_channel_id.assert_called_once_with(4123123)
+        obj.fetch_dm_channel.assert_not_called()
+        obj.app.rest.create_message.assert_awaited_once_with(
+            channel=obj.app.cache.get_dm_channel_id.return_value,
+            content="test",
+            embed=embed,
+            attachment=attachment,
+            attachments=attachments,
+            nonce="nonce",
+            tts=True,
+            mentions_everyone=False,
+            reply=reply,
+            user_mentions=user_mentions,
+            role_mentions=role_mentions,
+            mentions_reply=mentions_reply,
+        )
+
+    @pytest.mark.asyncio()
+    async def test_send_when_not_cached(self, obj):
+        obj.id = 522234
+        embed = object()
+        attachment = object()
+        attachments = [object(), object()]
+        user_mentions = [object(), object()]
+        role_mentions = [object(), object()]
+        reply = object()
+        mentions_reply = object()
+
+        obj.app = mock.Mock(spec=traits.CacheAware, rest=mock.AsyncMock())
+        obj.app.cache.get_dm_channel_id = mock.Mock(return_value=None)
+        obj.fetch_dm_channel = mock.AsyncMock()
+
+        returned = await obj.send(
+            content="test",
+            embed=embed,
+            attachment=attachment,
+            attachments=attachments,
+            nonce="nonce",
+            tts=True,
+            reply=reply,
+            mentions_everyone=False,
+            user_mentions=user_mentions,
+            role_mentions=role_mentions,
+            mentions_reply=mentions_reply,
+        )
+
+        assert returned is obj.app.rest.create_message.return_value
+
+        obj.app.cache.get_dm_channel_id.assert_called_once_with(522234)
+        obj.fetch_dm_channel.assert_awaited_once()
+        obj.app.rest.create_message.assert_awaited_once_with(
+            channel=obj.fetch_dm_channel.return_value.id,
+            content="test",
+            embed=embed,
+            attachment=attachment,
+            attachments=attachments,
+            nonce="nonce",
+            tts=True,
+            mentions_everyone=False,
+            reply=reply,
+            user_mentions=user_mentions,
+            role_mentions=role_mentions,
+            mentions_reply=mentions_reply,
+        )
+
+    @pytest.mark.asyncio()
+    async def test_send_when_not_cache_aware(self, obj):
+        obj.id = 522234
+        embed = object()
+        attachment = object()
+        attachments = [object(), object()]
+        user_mentions = [object(), object()]
+        role_mentions = [object(), object()]
+        reply = object()
+        mentions_reply = object()
+
+        obj.app = mock.Mock(spec=traits.RESTAware, rest=mock.AsyncMock())
+        obj.fetch_dm_channel = mock.AsyncMock()
 
         returned = await obj.send(
             content="test",
@@ -75,11 +168,11 @@ async def test_send(self, obj):
             mentions_reply=mentions_reply,
         )
 
-        assert returned is mock_message
+        assert returned is obj.app.rest.create_message.return_value
 
-        obj.fetch_dm_channel.assert_awaited_once_with()
+        obj.fetch_dm_channel.assert_awaited_once()
         obj.app.rest.create_message.assert_awaited_once_with(
-            channel=456,
+            channel=obj.fetch_dm_channel.return_value.id,
             content="test",
             embed=embed,
             attachment=attachment,
@@ -93,6 +186,16 @@ async def test_send(self, obj):
             mentions_reply=mentions_reply,
         )
 
+    @pytest.mark.asyncio()
+    async def test_fetch_dm_channel(self, obj):
+        obj.id = 123
+        obj.app = mock.Mock()
+        obj.app.rest.create_dm_channel = mock.AsyncMock()
+
+        assert await obj.fetch_dm_channel() == obj.app.rest.create_dm_channel.return_value
+
+        obj.app.rest.create_dm_channel.assert_awaited_once_with(123)
+
 
 class TestUser:
     @pytest.fixture()
@@ -202,30 +305,6 @@ def test_str_operator_when_partial(self, obj):
     def test_mention_property(self, obj):
         assert obj.mention == "<@123>"
 
-    @pytest.mark.asyncio()
-    async def test_fetch_dm_channel_when_not_cached(self, obj):
-        mock_channel = mock.Mock(id=456)
-
-        obj.id = 123
-        obj.app = mock.Mock()
-        obj.app.rest.create_dm_channel = mock.AsyncMock(return_value=mock_channel)
-        obj._dm_channel = None
-
-        assert await obj.fetch_dm_channel() == mock_channel
-
-        assert obj._dm_channel == mock_channel
-        obj.app.rest.create_dm_channel.assert_awaited_once_with(123)
-
-    @pytest.mark.asyncio()
-    async def test_fetch_dm_channel_when_cached(self, obj):
-        mock_channel = mock.Mock(id=456)
-        obj._dm_channel = mock_channel
-
-        assert await obj.fetch_dm_channel() == mock_channel
-
-        assert obj._dm_channel == mock_channel
-        obj.app.rest.create_dm_channel.assert_not_called()
-
     @pytest.mark.asyncio()
     async def test_fetch_self(self, obj):
         user = object()