Repository: interactions-py/interactions.py
Issue: [BUG] Members cache does not function as intended

### Describe the bug.

`interactions` currently has a (somewhat internal) cache for various items like users, roles, channels, etc. It also has one for members, too.

This is obviously a nice feature for developers; however, it does not function correctly. It assumes that a member's ID *alone* can be used as the key for a member. However, a member with the same ID from one guild *is not* the same as a member with the same ID from another guild.

(To simplify that: if you're in two guilds, in the API, despite having the same user ID everywhere, the "you" in each guild is different with differing nicknames, roles, etc., right? The cache forgot about that.)

### List the steps.

1. Fetch a member with a set ID from a guild, guild X.
2. Use the cache to get a member with the same ID as the above from another guild, guild Y.

### What you expected.

In this case, nothing should be returned as we never fetched the member from guild Y.

### What you saw.

The member object for guild X.

### What version of the library did you use?

`release`

### Version specification

4.1.0, though this hasn't been fixed in unstable either.

### Code of Conduct

- [X] I agree to follow the contribution requirements.

Changed files:
interactions/client/bot.py, interactions/api/http/message.py, interactions/api/http/channel.py, interactions/client/context.py, interactions/api/http/guild.py, interactions/api/models/attrs_utils.py, interactions/api/cache.py, interactions/api/http/thread.py, interactions/api/http/user.py, interactions/api/gateway/client.py

diff --git a/interactions/api/cache.py b/interactions/api/cache.py
index 98b8f67c..2651df7f 100644
--- a/interactions/api/cache.py
+++ b/interactions/api/cache.py
@@ -1,88 +1,73 @@
-from collections import OrderedDict
-from typing import Any, List, Optional
+from collections import defaultdict
+from typing import TYPE_CHECKING, Dict, Generic, List, Optional, Tuple, Type, TypeVar, Union
+
+if TYPE_CHECKING:
+    from .models import Snowflake
+
+    Key = TypeVar("Key", Snowflake, Tuple[Snowflake, Snowflake])
 
 __all__ = (
-    "Item",
     "Storage",
     "Cache",
 )
 
+_T = TypeVar("_T")
+_P = TypeVar("_P")
 
-class Item:
-    """
-    A class representing the defined item in a stored dataset.
 
-    :ivar str id: The ID of the item.
-    :ivar Any value: The item itself.
-    :ivar Type type: The ID type representation.
-    """
-
-    __slots__ = ("id", "value", "type")
-
-    def __init__(self, id: str, value: Any) -> None:
-        """
-        :param id: The item's ID.
-        :type id: str
-        :param value: The item itself.
-        :type value: Any
-        """
-        self.id = id
-        self.value = value
-        self.type = type(value)
-
-
-class Storage:
+class Storage(Generic[_T]):
     """
     A class representing a set of items stored as a cache state.
 
-    :ivar List[Item] values: The list of items stored.
+    :ivar Dict[Union[Snowflake, Tuple[Snowflake, Snowflake]], Any] values: The list of items stored.
     """
 
-    __slots__ = "values"
+    __slots__ = ("values",)
 
     def __repr__(self) -> str:
         return f"<{self.__class__.__name__} object containing {len(self.values)} items.>"
 
     def __init__(self) -> None:
-        self.values = OrderedDict()
+        self.values: Dict["Key", _T] = {}
 
-    def add(self, item: Item) -> OrderedDict:
+    def add(self, item: _T, id: Optional["Key"] = None) -> None:
         """
         Adds a new item to the storage.
 
         :param item: The item to add.
-        :type item: Item
-        :return: The new storage.
-        :rtype: OrderedDict
+        :type item: Any
+        :param id: The unique id of the item.
+        :type id: Optional[Union[Snowflake, Tuple[Snowflake, Snowflake]]]
         """
-        self.values.update({item.id: item.value})
-        return self.values
+        self.values[id or item.id] = item
 
-    def get(self, id: str) -> Optional[Item]:
+    def get(self, id: "Key", default: Optional[_P] = None) -> Union[_T, _P]:
         """
         Gets an item from the storage.
 
         :param id: The ID of the item.
-        :type id: str
+        :type id: Union[Snowflake, Tuple[Snowflake, Snowflake]]
+        :param default: The default value to return if the item is not found.
+        :type default: Optional[Any]
         :return: The item from the storage if any.
-        :rtype: Optional[Item]
+        :rtype: Optional[Any]
         """
-        if id in self.values.keys():
-            return self.values[id]
+        return self.values.get(id, default)
 
-    def update(self, item: Item) -> Optional[Item]:
+    def update(self, data: Dict["Key", _T]):
         """
-        Updates an item from the storage.
+        Updates multiple items from the storage.
 
-        :param item: The item to update.
-        :return: The updated item, if stored.
-        :rtype: Optional[Item]
+        :param data: The data to update with.
+        :type data: dict
         """
-        if item.id in self.values.keys():
-            self.values[item.id] = item.value
-            return self.values[
-                id
-            ]  # fetches from cache to see if its saved properly, instead of returning input.
+        self.values.update(data)
+
+    def pop(self, key: "Key", default: Optional[_P] = None) -> Union[_T, _P]:
+        try:
+            return self.values.pop(key)
+        except KeyError:
+            return default
 
     @property
     def view(self) -> List[dict]:
@@ -93,6 +78,15 @@ class Storage:
         """
         return [v._json for v in self.values.values()]
 
+    def __getitem__(self, item: "Key") -> _T:
+        return self.values.__getitem__(item)
+
+    def __setitem__(self, key: "Key", value: _T) -> None:
+        return self.values.__setitem__(key, value)
+
+    def __delitem__(self, key: "Key") -> None:
+        return self.values.__delitem__(key)
+
 
 class Cache:
     """
@@ -100,38 +94,16 @@ class Cache:
     This cache collects all of the HTTP requests made for
     the represented instances of the class.
 
-    :ivar Cache dms: The cached Direct Messages.
-    :ivar Cache self_guilds: The cached guilds upon gateway connection.
-    :ivar Cache guilds: The cached guilds after ready.
-    :ivar Cache channels: The cached channels of guilds.
-    :ivar Cache roles: The cached roles of guilds.
-    :ivar Cache members: The cached members of guilds and threads.
-    :ivar Cache messages: The cached messages of DMs and channels.
-    :ivar Cache interactions: The cached interactions upon interaction.
+    :ivar defaultdict[Type, Storage] storages:
     """
 
-    __slots__ = (
-        "dms",
-        "self_guilds",
-        "guilds",
-        "channels",
-        "roles",
-        "members",
-        "messages",
-        "users",
-        "interactions",
-    )
+    __slots__ = "storages"
 
     def __init__(self) -> None:
-        self.dms = Storage()
-        self.self_guilds = Storage()
-        self.guilds = Storage()
-        self.channels = Storage()
-        self.roles = Storage()
-        self.members = Storage()
-        self.messages = Storage()
-        self.users = Storage()
-        self.interactions = Storage()
+        self.storages: defaultdict[Type[_T], Storage[_T]] = defaultdict(Storage)
+
+    def __getitem__(self, item: Type[_T]) -> Storage[_T]:
+        return self.storages[item]
 
 
 ref_cache = Cache()  # noqa
diff --git a/interactions/api/gateway/client.py b/interactions/api/gateway/client.py
index e02d71b4..71b1c1bd 100644
--- a/interactions/api/gateway/client.py
+++ b/interactions/api/gateway/client.py
@@ -28,11 +28,14 @@ from ..error import LibraryException
 from ..http.client import HTTPClient
 from ..models.attrs_utils import MISSING
 from ..models.flags import Intents
+from ..models.member import Member
+from ..models.misc import Snowflake
 from ..models.presence import ClientPresence
 from .heartbeat import _Heartbeat
 
 if TYPE_CHECKING:
     from ...client.context import _Context
+    from ..cache import Storage
 
 log = get_logger("gateway")
 
@@ -273,12 +276,11 @@ class WebSocketClient:
         :param data: The data for the event.
         :type data: dict
         """
-        # sourcery no-metrics
+        self._dispatch.dispatch("raw_socket_create", data)
         path: str = "interactions"
         path += ".models" if event == "INTERACTION_CREATE" else ".api.models"
         if event == "INTERACTION_CREATE":
             if data.get("type"):
-                # sourcery skip: dict-assign-update-to-union, extract-method, low-code-quality
                 _context = self.__contextualize(data)
                 _name: str = ""
                 __args: list = [_context]
@@ -386,17 +388,42 @@ class WebSocketClient:
             try:
                 _event_path: list = [section.capitalize() for section in name.split("_")]
                 _name: str = _event_path[0] if len(_event_path) < 3 else "".join(_event_path[:-1])
-                __obj: object = getattr(__import__(path), _name)
+                model = getattr(__import__(path), _name)
+
+                data["_client"] = self._http
+                obj = model(**data)
+                _cache: "Storage" = self._http.cache[model]
+
+                if isinstance(obj, Member):
+                    id = (Snowflake(data["guild_id"]), obj.id)
+                else:
+                    id = getattr(obj, "id", None)
+
+                if "_create" in name or "_add" in name:
+                    _cache.add(obj, id)
+                    self._dispatch.dispatch(f"on_{name}", obj)
 
-                # name in {"_create", "_add"} returns False (tested w message_create)
-                if any(_ in name for _ in {"_create", "_update", "_add", "_remove", "_delete"}):
-                    data["_client"] = self._http
+                elif "_update" in name and hasattr(obj, "id"):
+                    old_obj = self._http.cache[model].get(id)
+                    _cache.add(obj, id)
+                    copy = model(**old_obj._json)
+                    old_obj.update(**obj._json)
+                    self._dispatch.dispatch(
+                        f"on_{name}", copy, old_obj
+                    )  # give previously stored and new one
+                    return
 
-                self._dispatch.dispatch(f"on_{name}", __obj(**data))  # noqa
+                elif "_remove" in name or "_delete" in name:
+                    self._dispatch.dispatch(f"on_raw_{name}", obj)
+
+                    old_obj = _cache.pop(id)
+                    self._dispatch.dispatch(f"on_{name}", old_obj)
+
+                else:
+                    self._dispatch.dispatch(f"on_{name}", obj)
 
             except AttributeError as error:
                 log.fatal(f"An error occured dispatching {name}: {error}")
-        self._dispatch.dispatch("raw_socket_create", data)
 
     def __contextualize(self, data: dict) -> "_Context":
         """
diff --git a/interactions/api/http/channel.py b/interactions/api/http/channel.py
index 9db449fc..6c25c62c 100644
--- a/interactions/api/http/channel.py
+++ b/interactions/api/http/channel.py
@@ -1,6 +1,6 @@
 from typing import Dict, List, Optional, Union
 
-from ...api.cache import Cache, Item
+from ...api.cache import Cache
 from ..error import LibraryException
 from ..models.channel import Channel
 from ..models.message import Message
@@ -11,7 +11,6 @@ __all__ = ("ChannelRequest",)
 
 
 class ChannelRequest:
-
     _req: _Request
     cache: Cache
 
@@ -26,7 +25,7 @@ class ChannelRequest:
         :return: Dictionary of the channel object.
         """
         request = await self._req.request(Route("GET", f"/channels/{channel_id}"))
-        self.cache.channels.add(Item(id=str(channel_id), value=Channel(**request, _client=self)))
+        self.cache[Channel].add(Channel(**request, _client=self))
 
         return request
 
@@ -88,7 +87,7 @@ class ChannelRequest:
         if isinstance(request, list):
             for message in request:
                 if message.get("id"):
-                    self.cache.messages.add(Item(id=message["id"], value=Message(**message)))
+                    self.cache[Message].add(Message(**message))
 
         return request
 
@@ -110,7 +109,7 @@ class ChannelRequest:
             Route("POST", f"/guilds/{guild_id}/channels"), json=payload, reason=reason
         )
         if request.get("id"):
-            self.cache.channels.add(Item(id=request["id"], value=Channel(**request)))
+            self.cache[Channel].add(Channel(**request))
 
         return request
 
diff --git a/interactions/api/http/guild.py b/interactions/api/http/guild.py
index 2535cb94..49f8211d 100644
--- a/interactions/api/http/guild.py
+++ b/interactions/api/http/guild.py
@@ -1,7 +1,7 @@
 from typing import Any, Dict, List, Optional
 from urllib.parse import quote
 
-from ...api.cache import Cache, Item
+from ...api.cache import Cache
 from ..models.channel import Channel
 from ..models.guild import Guild
 from ..models.member import Member
@@ -30,7 +30,7 @@ class GuildRequest:
 
         for guild in request:
             if guild.get("id"):
-                self.cache.self_guilds.add(Item(id=guild["id"], value=Guild(**guild, _client=self)))
+                self.cache[Guild].add(Guild(**guild, _client=self))
 
         return request
 
@@ -45,7 +45,7 @@ class GuildRequest:
         request = await self._req.request(
             Route("GET", f"/guilds/{guild_id}{f'?{with_counts=}' if with_counts else ''}")
         )
-        self.cache.guilds.add(Item(id=str(guild_id), value=Guild(**request, _client=self)))
+        self.cache[Guild].add(Guild(**request, _client=self))
 
         return request
 
@@ -369,9 +369,7 @@ class GuildRequest:
 
         for channel in request:
             if channel.get("id"):
-                self.cache.channels.add(
-                    Item(id=channel["id"], value=Channel(**channel, _client=self))
-                )
+                self.cache[Channel].add(Channel(**channel, _client=self))
 
         return request
 
@@ -388,7 +386,7 @@ class GuildRequest:
 
         for role in request:
             if role.get("id"):
-                self.cache.roles.add(Item(id=role["id"], value=Role(**role)))
+                self.cache[Role].add(Role(**role))
 
         return request
 
@@ -407,7 +405,7 @@ class GuildRequest:
             Route("POST", f"/guilds/{guild_id}/roles"), json=payload, reason=reason
         )
         if request.get("id"):
-            self.cache.roles.add(Item(id=request["id"], value=Role(**request)))
+            self.cache[Role].add(Role(**request))
 
         return request
 
@@ -590,7 +588,7 @@ class GuildRequest:
             },
         )
 
-        self.cache.members.add(Item(id=str(user_id), value=Member(**request)))
+        self.cache[Member].add(Member(**request))
 
         return request
 
diff --git a/interactions/api/http/message.py b/interactions/api/http/message.py
index 47b8978f..da2c9833 100644
--- a/interactions/api/http/message.py
+++ b/interactions/api/http/message.py
@@ -2,7 +2,7 @@ from typing import List, Optional, Union
 
 from aiohttp import MultipartWriter
 
-from ...api.cache import Cache, Item
+from ...api.cache import Cache
 from ..models.attrs_utils import MISSING
 from ..models.message import Embed, Message, MessageInteraction, Sticker
 from ..models.misc import File, Snowflake
@@ -98,7 +98,7 @@ class MessageRequest:
             data=data,
         )
         if request.get("id"):
-            self.cache.messages.add(Item(id=request["id"], value=Message(**request, _client=self)))
+            self.cache[Message].add(Message(**request, _client=self))
 
         return request
 
diff --git a/interactions/api/http/thread.py b/interactions/api/http/thread.py
index 6fbbaace..6523e187 100644
--- a/interactions/api/http/thread.py
+++ b/interactions/api/http/thread.py
@@ -1,6 +1,7 @@
 from typing import Dict, List, Optional
 
-from ...api.cache import Cache, Item
+from ...api.cache import Cache
+from ..models.channel import Channel
 from .request import _Request
 from .route import Route
 
@@ -8,7 +9,6 @@ __all__ = ("ThreadRequest",)
 
 
 class ThreadRequest:
-
     _req: _Request
     cache: Cache
 
@@ -177,8 +177,8 @@ class ThreadRequest:
                 reason=reason,
             )
             if request.get("id"):
-                self.cache.channels.add(Item(id=request["id"], value=request))
-            return request
+                self.cache[Channel].add(Channel(**request))
+                return request
 
         payload["type"] = thread_type
         payload["invitable"] = invitable
@@ -186,6 +186,6 @@ class ThreadRequest:
             Route("POST", f"/channels/{channel_id}/threads"), json=payload, reason=reason
         )
         if request.get("id"):
-            self.cache.channels.add(Item(id=request["id"], value=request))
+            self.cache[Channel].add(Channel(**request))
 
         return request
diff --git a/interactions/api/http/user.py b/interactions/api/http/user.py
index 2fe8ed9e..a6f3af89 100644
--- a/interactions/api/http/user.py
+++ b/interactions/api/http/user.py
@@ -1,6 +1,6 @@
 from typing import Optional
 
-from ...api.cache import Cache, Item
+from ...api.cache import Cache
 from ..models.channel import Channel
 from ..models.user import User
 from .request import _Request
@@ -37,7 +37,7 @@ class UserRequest:
             user_id = "@me"
 
         request = await self._req.request(Route("GET", f"/users/{user_id}"))
-        self.cache.users.add(Item(id=user_id, value=User(**request)))
+        self.cache[User].add(User(**request))
 
         return request
 
@@ -74,6 +74,6 @@ class UserRequest:
         request = await self._req.request(
             Route("POST", "/users/@me/channels"), json={"recipient_id": recipient_id}
         )
-        self.cache.dms.add(Item(id=str(recipient_id), value=Channel(**request)))
+        self.cache[Channel].add(Channel(**request))
 
         return request
diff --git a/interactions/api/models/attrs_utils.py b/interactions/api/models/attrs_utils.py
index 9f28c985..6f14f076 100644
--- a/interactions/api/models/attrs_utils.py
+++ b/interactions/api/models/attrs_utils.py
@@ -86,6 +86,10 @@ class DictSerializerMixin:
 
         :param dict kwargs_dict: The dictionary to update from
         """
+        # idiot check to make sure it's a dictionary (yes, that includes myself)
+        if isinstance(kwargs_dict, DictSerializerMixin):
+            kwargs_dict = kwargs_dict._json
+
         kwargs = kwargs_dict or other_kwargs
         attribs: Dict[str, attrs.Attribute] = {
             attrib.name: attrib for attrib in self.__attrs_attrs__
@@ -96,6 +100,9 @@ class DictSerializerMixin:
                 self._extras[name] = value
                 continue
 
+            if value is None:
+                continue
+
             if converter := attribs[name].converter:
                 value = converter(value)
 
diff --git a/interactions/client/bot.py b/interactions/client/bot.py
index b8266396..f75efed0 100644
--- a/interactions/client/bot.py
+++ b/interactions/client/bot.py
@@ -10,13 +10,14 @@ from logging import Logger
 from types import ModuleType
 from typing import Any, Callable, Coroutine, Dict, List, Optional, Tuple, Union
 
-from ..api import Item as Build
 from ..api import WebSocketClient as WSClient
 from ..api.error import LibraryException
 from ..api.http.client import HTTPClient
 from ..api.models.attrs_utils import MISSING
+from ..api.models.channel import Channel
 from ..api.models.flags import Intents, Permissions
 from ..api.models.guild import Guild
+from ..api.models.message import Message
 from ..api.models.misc import Image, Snowflake
 from ..api.models.presence import ClientPresence
 from ..api.models.team import Application
@@ -117,10 +118,7 @@ class Client:
     def guilds(self) -> List[Guild]:
         """Returns a list of guilds the bot is in."""
 
-        return [
-            Guild(**_) if _.get("_client") else Guild(**_, _client=self._http)
-            for _ in self._http.cache.self_guilds.view
-        ]
+        return list(self._http.cache[Guild].values.values())
 
     @property
     def latency(self) -> float:
@@ -1450,7 +1448,7 @@ class Client:
         :return: The channel as a dictionary of raw data.
         :rtype: dict
         """
-        self._http.cache.channels.add(Build(id=channel.id, value=channel))
+        self._http.cache[Channel].add(channel)
 
         return channel._json
 
@@ -1463,7 +1461,7 @@ class Client:
         :return: The message as a dictionary of raw data.
         :rtype: dict
         """
-        self._http.cache.messages.add(Build(id=message.id, value=message))
+        self._http.cache[Message].add(message)
 
         return message._json
 
@@ -1476,7 +1474,7 @@ class Client:
         :return: The guild as a dictionary of raw data.
         :rtype: dict
         """
-        self._http.cache.self_guilds.add(Build(id=str(guild.id), value=guild))
+        self._http.cache[Guild].add(guild)
 
         return guild._json
 
diff --git a/interactions/client/context.py b/interactions/client/context.py
index 740354ad..ab78a539 100644
--- a/interactions/client/context.py
+++ b/interactions/client/context.py
@@ -73,10 +73,10 @@ class _Context(ClientSerializerMixin):
             self.user = self.member.user if self.member else None
 
         if self.guild is None and self.guild_id is not None:
-            self.guild = self._client.cache.guilds.values.get(self.guild_id, MISSING)
+            self.guild = self._client.cache[Guild].get(self.guild_id, MISSING)
 
         if self.channel is None:
-            self.channel = self._client.cache.channels.values.get(self.channel_id, MISSING)
+            self.channel = self._client.cache[Channel].get(self.channel_id, MISSING)
 
     async def get_channel(self) -> Channel:
         """