api-surface #1

Merged
luna merged 51 commits from api-surface into mistress 2026-07-05 14:43:13 +00:00
8 changed files with 406 additions and 444 deletions
Showing only changes of commit 1c35130775 - Show all commits

feat(cache): structs keyed by integer snowflakes; dispatcher decodes once

Luna 2026-07-05 07:14:26 -03:00

View file

@ -10,7 +10,7 @@ defmodule Dexcord.Cache do
## Single writer, lock-free reads
This GenServer only *creates* the tables (in `init/1`); it never writes to them
on the hot path. All writes happen through `handle_dispatch/3`, which the
on the hot path. All writes happen through `handle_dispatch/4`, which the
`Dexcord.Dispatcher` calls **inline, in its own process, in gateway order** -
making the Dispatcher the sole writer. Because there is exactly one writer, no
locks are needed. Every table is `:public`, so reads (`guild/1`, `member/2`, ...)
@ -36,16 +36,20 @@ defmodule Dexcord.Cache do
### Key and value conventions
Snowflake ids are stored in table keys as the **raw strings Discord sends** - no
integer parsing. This keeps lookups cheap and avoids precision pitfalls, at the
cost that a caller must pass string ids (which is what every payload already
carries). Stored values are the original string-keyed payload maps (no structs,
no atom keys), with the large child arrays of a guild peeled off into their own
tables. The `ordered_set` tables use `{guild_id, x}` keys so a whole guild can be
listed with a key-prefix select and purged with a single `match_delete/2`.
Snowflake ids are stored in table keys as **integers** - the same representation
`Dexcord.Snowflake.cast/1` produces at decode time. Reads accept anything
castable (an integer, a decimal string, or a struct with an `:id`) and normalize
it, so a caller may pass whatever id they have. Stored values are decoded model
**structs** (`%Dexcord.Guild{}`, `%Dexcord.TextChannel{}`, `%Dexcord.Member{}`,
...), with the large child arrays of a guild peeled off into their own tables. A
cached member's nested `user` is moved into the users table and the row keeps a
`user_id` back-reference. The `ordered_set` tables use `{guild_id, x}` keys so a
whole guild can be listed with a key-prefix select and purged with a single
`match_delete/2`.
"""
use GenServer
require Logger
@me :dexcord_me
@guilds :dexcord_guilds
@ -58,10 +62,7 @@ defmodule Dexcord.Cache do
# Guild child collections lifted out of the guild row into their own tables.
# `emojis` (and stickers) stay inline on the guild and are replaced wholesale.
@guild_child_arrays ~w(channels threads roles members presences voice_states)
# Channel `type` values that denote a thread (public, private, announcement).
@thread_types [10, 11, 12]
@guild_child_fields [:channels, :threads, :roles, :members, :presences, :voice_states]
# --- lifecycle ----------------------------------------------------------
@ -90,260 +91,267 @@ defmodule Dexcord.Cache do
# --- write path (called inline by the Dispatcher, single writer) ---------
@doc """
Folds one dispatch event into the cache. Called inline by `Dexcord.Dispatcher`
*before* the user handler runs, so the handler always observes a cache that
already reflects the event.
Folds one decoded dispatch event into the cache. Called inline by
`Dexcord.Dispatcher` *before* the user handler runs, so the handler always
observes a cache that already reflects the event.
`decoded` is the typed payload from `Dexcord.Events.decode/2`; `raw` is the
original string-keyed wire map (needed by partial-update events that merge only
the keys Discord actually sent). Write clauses pattern-match the decoded struct
type, so a decode failure (which delivers the raw map as a fallback) simply
matches no write clause and is skipped.
`config` is the resolved `Dexcord.Config` map; only `:cache_presences` is
consulted (presence writes are skipped entirely when it is falsey). Unknown or
uninteresting events - and any event whose data is not a map - are a no-op.
Returns `:ok`.
consulted (presence writes are skipped entirely when it is falsey). Returns `:ok`.
"""
@spec handle_dispatch(atom() | {:unknown_event, String.t()}, term(), map()) :: :ok
def handle_dispatch(name, data, config)
@spec handle_dispatch(atom() | {:unknown_event, String.t()}, term(), term(), map()) :: :ok
def handle_dispatch(name, decoded, raw, config)
def handle_dispatch(_name, data, _config) when not is_map(data), do: :ok
def handle_dispatch(:READY, %Dexcord.Events.Ready{} = ready, _raw, _config) do
put_me(ready.user)
def handle_dispatch(:READY, data, _config) do
if user = data["user"], do: put_me(user)
for g <- data["guilds"] || [],
is_map(g) and is_binary(g["id"]),
do: :ets.insert(@guilds, {g["id"], g})
for %Dexcord.UnavailableGuild{id: id} = stub <- ready.guilds, not is_nil(id) do
:ets.insert(@guilds, {id, stub})
end
:ok
end
def handle_dispatch(:GUILD_CREATE, guild, config) do
gid = guild["id"]
:ets.insert(@guilds, {gid, Map.drop(guild, @guild_child_arrays)})
def handle_dispatch(:GUILD_CREATE, %Dexcord.Guild{} = guild, _raw, config) do
:ets.insert(@guilds, {guild.id, without_children(guild)})
for ch <- guild["channels"] || [], do: put_channel(Map.put(ch, "guild_id", gid))
for th <- guild["threads"] || [], do: put_channel(Map.put(th, "guild_id", gid))
for ch <- guild.channels ++ guild.threads, do: put_guild_channel(ch, guild.id)
for role <- guild["roles"] || [],
is_binary(role["id"]),
do: :ets.insert(@roles, {{gid, role["id"]}, role})
for %Dexcord.Role{id: rid} = role <- guild.roles, not is_nil(rid) do
:ets.insert(@roles, {{guild.id, rid}, role})
end
for vs <- guild["voice_states"] || [], do: put_voice_state(gid, vs)
for m <- guild["members"] || [], do: put_member(gid, m)
for vs <- guild.voice_states, do: put_voice_state(guild.id, vs)
for m <- guild.members, do: put_member(guild.id, m)
if cache_presences?(config) do
for p <- guild["presences"] || [], do: put_presence(gid, p)
for p <- guild.presences, do: put_presence(guild.id, p)
end
:ok
end
def handle_dispatch(:GUILD_UPDATE, guild, _config) do
gid = guild["id"]
merged = Map.merge(existing(@guilds, gid), Map.drop(guild, @guild_child_arrays))
:ets.insert(@guilds, {gid, merged})
# Task 1 placeholder: full replace (minus child arrays). Task 2 replaces this
# with a `merge_map/2` that preserves gateway-only fields like `joined_at`.
def handle_dispatch(:GUILD_UPDATE, %Dexcord.Guild{} = guild, _raw, _config) do
:ets.insert(@guilds, {guild.id, without_children(guild)})
:ok
end
def handle_dispatch(:GUILD_DELETE, data, _config) do
gid = data["id"]
def handle_dispatch(name, channel, _raw, _config)
when name in [:CHANNEL_CREATE, :CHANNEL_UPDATE, :THREAD_CREATE, :THREAD_UPDATE] and
is_struct(channel) and not is_struct(channel, Dexcord.UnknownChannel) do
put_channel(channel)
:ok
end
if data["unavailable"] == true do
# Guild went unavailable (outage), not removed: keep a stub placeholder.
:ets.insert(@guilds, {gid, data})
else
:ets.delete(@guilds, gid)
:ets.match_delete(@members, {{gid, :_}, :_})
:ets.match_delete(@roles, {{gid, :_}, :_})
:ets.match_delete(@presences, {{gid, :_}, :_})
:ets.match_delete(@voice_states, {{gid, :_}, :_})
:ets.select_delete(@channels, [{{:_, %{"guild_id" => gid}}, [], [true]}])
def handle_dispatch(:CHANNEL_DELETE, channel, _raw, _config)
when is_struct(channel) and not is_struct(channel, Dexcord.UnknownChannel) do
if id = Map.get(channel, :id), do: :ets.delete(@channels, id)
:ok
end
def handle_dispatch(:THREAD_DELETE, %Dexcord.Events.ThreadDelete{id: id}, _raw, _config)
when not is_nil(id) do
:ets.delete(@channels, id)
:ok
end
def handle_dispatch(:GUILD_MEMBER_ADD, %Dexcord.Member{} = member, _raw, _config) do
put_member(member.guild_id, member)
:ok
end
def handle_dispatch(
:GUILD_MEMBER_REMOVE,
%Dexcord.Events.GuildMemberRemove{guild_id: gid, user: %Dexcord.User{id: uid}},
_raw,
_config
)
when not is_nil(gid) and not is_nil(uid) do
:ets.delete(@members, {gid, uid})
:ok
end
def handle_dispatch(
:GUILD_MEMBERS_CHUNK,
%Dexcord.Events.GuildMembersChunk{} = chunk,
_raw,
config
) do
for m <- chunk.members, do: put_member(chunk.guild_id, m)
if cache_presences?(config) do
for p <- chunk.presences, do: put_presence(chunk.guild_id, p)
end
:ok
end
def handle_dispatch(name, ch, _config)
when name in [:CHANNEL_CREATE, :CHANNEL_UPDATE, :THREAD_CREATE, :THREAD_UPDATE] do
put_channel(ch)
def handle_dispatch(name, %{guild_id: gid, role: %Dexcord.Role{id: rid} = role}, _raw, _config)
when name in [:GUILD_ROLE_CREATE, :GUILD_ROLE_UPDATE] and not is_nil(gid) and
not is_nil(rid) do
:ets.insert(@roles, {{gid, rid}, role})
:ok
end
def handle_dispatch(name, ch, _config) when name in [:CHANNEL_DELETE, :THREAD_DELETE] do
if id = ch["id"], do: :ets.delete(@channels, id)
def handle_dispatch(
:GUILD_ROLE_DELETE,
%Dexcord.Events.GuildRoleDelete{guild_id: gid, role_id: rid},
_raw,
_config
)
when not is_nil(gid) and not is_nil(rid) do
:ets.delete(@roles, {gid, rid})
:ok
end
def handle_dispatch(name, member, _config)
when name in [:GUILD_MEMBER_ADD, :GUILD_MEMBER_UPDATE] do
put_member(member["guild_id"], member)
:ok
end
def handle_dispatch(:GUILD_MEMBER_REMOVE, data, _config) do
with gid when is_binary(gid) <- data["guild_id"],
%{"id" => uid} <- data["user"] do
:ets.delete(@members, {gid, uid})
end
:ok
end
def handle_dispatch(:GUILD_MEMBERS_CHUNK, data, config) do
gid = data["guild_id"]
for m <- data["members"] || [], do: put_member(gid, m)
presences = data["presences"]
if is_list(presences) and cache_presences?(config) do
for p <- presences, do: put_presence(gid, p)
end
:ok
end
def handle_dispatch(name, data, _config)
when name in [:GUILD_ROLE_CREATE, :GUILD_ROLE_UPDATE] do
gid = data["guild_id"]
case data["role"] do
%{"id" => rid} = role -> :ets.insert(@roles, {{gid, rid}, role})
_ -> :ok
end
:ok
end
def handle_dispatch(:GUILD_ROLE_DELETE, data, _config) do
if is_binary(data["guild_id"]) and is_binary(data["role_id"]),
do: :ets.delete(@roles, {data["guild_id"], data["role_id"]})
:ok
end
def handle_dispatch(:GUILD_EMOJIS_UPDATE, data, _config) do
gid = data["guild_id"]
case :ets.lookup(@guilds, gid) do
[{_, g}] -> :ets.insert(@guilds, {gid, Map.put(g, "emojis", data["emojis"] || [])})
[] -> :ok
end
:ok
end
def handle_dispatch(:PRESENCE_UPDATE, data, config) do
def handle_dispatch(:PRESENCE_UPDATE, %Dexcord.Presence{} = presence, _raw, config) do
# The chattiest event under `:all`: when presences aren't cached we skip the
# write entirely rather than build-then-discard a term.
if cache_presences?(config), do: put_presence(data["guild_id"], data)
if cache_presences?(config), do: put_presence(presence.guild_id, presence)
:ok
end
def handle_dispatch(:VOICE_STATE_UPDATE, data, _config) do
gid = data["guild_id"]
uid = data["user_id"]
def handle_dispatch(:VOICE_STATE_UPDATE, %Dexcord.VoiceState{} = vs, _raw, _config) do
cond do
is_nil(gid) or is_nil(uid) -> :ok
is_nil(data["channel_id"]) -> :ets.delete(@voice_states, {gid, uid})
true -> :ets.insert(@voice_states, {{gid, uid}, data})
is_nil(vs.guild_id) or is_nil(vs.user_id) -> :ok
is_nil(vs.channel_id) -> :ets.delete(@voice_states, {vs.guild_id, vs.user_id})
true -> :ets.insert(@voice_states, {{vs.guild_id, vs.user_id}, vs})
end
:ok
end
def handle_dispatch(:USER_UPDATE, data, _config) do
put_me(data)
def handle_dispatch(
:MESSAGE_CREATE,
%Dexcord.Message{author: %Dexcord.User{id: id} = author},
_raw,
_config
)
when is_integer(id) do
# Opportunistic freshness: cache the (real, non-webhook) message author. The
# richer member fold-in and webhook guard land in Task 2.
upsert_user(author)
:ok
end
def handle_dispatch(:MESSAGE_CREATE, data, _config) do
author = data["author"]
# Opportunistic freshness: cache the (real, non-webhook) author and, when the
# message carries a guild member fragment, fold it into the members table.
if is_map(author) and is_binary(author["id"]) and is_nil(author["webhook_id"]) and
is_nil(data["webhook_id"]) do
upsert_user(author)
with gid when is_binary(gid) <- data["guild_id"],
member when is_map(member) <- data["member"] do
uid = author["id"]
merged = existing(@members, {gid, uid}) |> Map.merge(member) |> Map.put("user_id", uid)
:ets.insert(@members, {{gid, uid}, merged})
end
end
:ok
end
# Everything else (unknown events, events with no cache mapping) is a no-op.
def handle_dispatch(_name, _data, _config), do: :ok
# Everything else - unknown events, events with no cache mapping, and any raw
# fallback map from a failed decode (which matches no struct clause) - is a
# no-op. The merge-flavored events (GUILD_UPDATE merge, GUILD_MEMBER_UPDATE,
# USER_UPDATE, GUILD_EMOJIS/STICKERS_UPDATE, GUILD_DELETE cascade) land in Task 2.
def handle_dispatch(_name, _decoded, _raw, _config), do: :ok
# --- write helpers ------------------------------------------------------
defp put_me(user) when is_map(user),
do: :ets.insert(@me, {:me, Map.merge(existing(@me, :me), user)})
defp without_children(%Dexcord.Guild{} = guild) do
Enum.reduce(@guild_child_fields, guild, fn field, acc -> Map.put(acc, field, []) end)
end
defp put_me(%Dexcord.User{} = user), do: :ets.insert(@me, {:me, user})
defp put_me(_), do: :ok
defp put_channel(%{"id" => id} = ch) when is_binary(id), do: :ets.insert(@channels, {id, ch})
# A guild's inline channels arrive without a `guild_id`; stamp it on the way in.
# `%Dexcord.UnknownChannel{}` has no `guild_id` field (and no stable id), so
# unknown-typed channels are a documented, bounded gap: they are not cached.
defp put_guild_channel(%Dexcord.UnknownChannel{}, _gid) do
Logger.debug("Dexcord.Cache: skipping unknown-typed channel in GUILD_CREATE fan-out")
:ok
end
defp put_guild_channel(%_{} = channel, gid), do: put_channel(%{channel | guild_id: gid})
defp put_channel(%_{id: id} = channel) when not is_nil(id),
do: :ets.insert(@channels, {id, channel})
defp put_channel(_), do: :ok
# A member's `user` object is moved into the users table; the member row keeps
# only a `"user_id"` back-reference. Merges over any existing row so partial
# updates (GUILD_MEMBER_UPDATE, MESSAGE_CREATE fragments) don't drop fields.
defp put_member(gid, %{"user" => %{"id" => uid} = user} = member) when is_binary(gid) do
# only a `user_id` back-reference. Members without a nested user are skipped.
defp put_member(gid, %Dexcord.Member{user: %Dexcord.User{id: uid} = user} = member)
when not is_nil(gid) and not is_nil(uid) do
upsert_user(user)
row = %{member | user: nil, user_id: uid}
merged =
existing(@members, {gid, uid})
|> Map.merge(Map.delete(member, "user"))
|> Map.put("user_id", uid)
case :ets.lookup(@members, {gid, uid}) do
[{_, %Dexcord.Member{} = existing}] -> merge_non_nil(existing, row)
_ -> row
end
:ets.insert(@members, {{gid, uid}, merged})
end
defp put_member(_gid, _member), do: :ok
defp put_voice_state(gid, %{"user_id" => uid} = vs) when is_binary(uid),
do: :ets.insert(@voice_states, {{gid, uid}, vs})
defp put_voice_state(gid, %Dexcord.VoiceState{user_id: uid} = vs)
when not is_nil(gid) and not is_nil(uid),
do: :ets.insert(@voice_states, {{gid, uid}, vs})
defp put_voice_state(_gid, _vs), do: :ok
defp put_presence(gid, %{"user" => %{"id" => uid}} = presence) when is_binary(gid),
do: :ets.insert(@presences, {{gid, uid}, presence})
# `presence.user` is `:raw` (Discord guarantees only its `id`); cast it here.
defp put_presence(gid, %Dexcord.Presence{user: user} = presence) when not is_nil(gid) do
case Dexcord.Snowflake.cast(user_id(user)) do
{:ok, uid} -> :ets.insert(@presences, {{gid, uid}, presence})
:error -> :ok
end
end
defp put_presence(_gid, _presence), do: :ok
defp upsert_user(%{"id" => uid} = user),
do: :ets.insert(@users, {uid, Map.merge(existing(@users, uid), user)})
defp user_id(%{"id" => id}), do: id
defp user_id(_), do: nil
defp upsert_user(%Dexcord.User{id: uid} = user) when not is_nil(uid) do
merged =
case :ets.lookup(@users, uid) do
[{_, %Dexcord.User{} = existing}] -> merge_non_nil(existing, user)
_ -> user
end
:ets.insert(@users, {uid, merged})
end
defp upsert_user(_), do: :ok
defp existing(table, key) do
case :ets.lookup(table, key) do
[{_, v}] -> v
[] -> %{}
end
# Overlay `new` onto `existing`, keeping `existing`'s value wherever `new`'s is
# nil - so a fuller cached row is never clobbered by a sparser struct.
defp merge_non_nil(%module{} = existing, %module{} = new) do
Map.merge(existing, Map.from_struct(new), fn _k, ev, nv ->
if is_nil(nv), do: ev, else: nv
end)
end
# --- read API -----------------------------------------------------------
@typedoc "A cached entity, as its original string-keyed payload map."
@type entity :: map()
@typedoc "A cached entity, as its decoded model struct."
@type entity :: struct()
@typedoc "Any snowflake-castable id: an integer, a decimal string, or a struct with `:id`."
@type id :: Dexcord.Snowflake.t() | String.t() | struct()
@doc "The bot's own user object (from READY / USER_UPDATE)."
@spec me() :: {:ok, entity()} | :error
@spec me() :: {:ok, Dexcord.User.t()} | :error
def me, do: fetch(@me, :me)
@doc "Bang variant of `me/0`; raises if unset."
@spec me!() :: entity()
@spec me!() :: Dexcord.User.t()
def me!, do: unwrap(me(), :me)
@doc "A guild by id."
@spec guild(String.t()) :: {:ok, entity()} | :error
def guild(id), do: fetch(@guilds, id)
@spec guild(id()) :: {:ok, entity()} | :error
def guild(id) do
with {:ok, id} <- Dexcord.Snowflake.cast(id), do: fetch(@guilds, id)
end
@doc "Bang variant of `guild/1`."
@spec guild!(String.t()) :: entity()
@spec guild!(id()) :: entity()
def guild!(id), do: unwrap(guild(id), id)
@doc "All cached guilds (including unavailable stubs)."
@ -351,82 +359,92 @@ defmodule Dexcord.Cache do
def guilds, do: all_values(@guilds)
@doc "A channel (or thread, or DM) by id."
@spec channel(String.t()) :: {:ok, entity()} | :error
def channel(id), do: fetch(@channels, id)
@spec channel(id()) :: {:ok, entity()} | :error
def channel(id) do
with {:ok, id} <- Dexcord.Snowflake.cast(id), do: fetch(@channels, id)
end
@doc "Bang variant of `channel/1`."
@spec channel!(String.t()) :: entity()
@spec channel!(id()) :: entity()
def channel!(id), do: unwrap(channel(id), id)
@doc "All cached channels/threads belonging to a guild."
@spec channels(String.t()) :: [entity()]
@spec channels(id()) :: [entity()]
def channels(guild_id) do
@channels
|> :ets.select([{{:_, %{"guild_id" => guild_id}}, [], [:"$_"]}])
|> Enum.map(&elem(&1, 1))
case Dexcord.Snowflake.cast(guild_id) do
{:ok, gid} ->
@channels
|> :ets.select([{{:_, %{guild_id: gid}}, [], [:"$_"]}])
|> Enum.map(&elem(&1, 1))
:error ->
[]
end
end
@doc "All cached threads (channel types 10/11/12) belonging to a guild."
@spec threads(String.t()) :: [entity()]
@doc "All cached threads belonging to a guild."
@spec threads(id()) :: [entity()]
def threads(guild_id) do
guild_id |> channels() |> Enum.filter(fn ch -> ch["type"] in @thread_types end)
guild_id |> channels() |> Enum.filter(&match?(%Dexcord.Thread{}, &1))
end
@doc "A guild member by guild id + user id."
@spec member(String.t(), String.t()) :: {:ok, entity()} | :error
def member(guild_id, user_id), do: fetch(@members, {guild_id, user_id})
@spec member(id(), id()) :: {:ok, entity()} | :error
def member(guild_id, user_id), do: pair_fetch(@members, guild_id, user_id)
@doc "Bang variant of `member/2`."
@spec member!(String.t(), String.t()) :: entity()
@spec member!(id(), id()) :: entity()
def member!(guild_id, user_id), do: unwrap(member(guild_id, user_id), {guild_id, user_id})
@doc "All cached members of a guild."
@spec members(String.t()) :: [entity()]
@spec members(id()) :: [entity()]
def members(guild_id), do: prefix_values(@members, guild_id)
@doc "A user by id."
@spec user(String.t()) :: {:ok, entity()} | :error
def user(id), do: fetch(@users, id)
@spec user(id()) :: {:ok, entity()} | :error
def user(id) do
with {:ok, id} <- Dexcord.Snowflake.cast(id), do: fetch(@users, id)
end
@doc "Bang variant of `user/1`."
@spec user!(String.t()) :: entity()
@spec user!(id()) :: entity()
def user!(id), do: unwrap(user(id), id)
@doc "A role by guild id + role id."
@spec role(String.t(), String.t()) :: {:ok, entity()} | :error
def role(guild_id, role_id), do: fetch(@roles, {guild_id, role_id})
@spec role(id(), id()) :: {:ok, entity()} | :error
def role(guild_id, role_id), do: pair_fetch(@roles, guild_id, role_id)
@doc "Bang variant of `role/2`."
@spec role!(String.t(), String.t()) :: entity()
@spec role!(id(), id()) :: entity()
def role!(guild_id, role_id), do: unwrap(role(guild_id, role_id), {guild_id, role_id})
@doc "All cached roles of a guild."
@spec roles(String.t()) :: [entity()]
@spec roles(id()) :: [entity()]
def roles(guild_id), do: prefix_values(@roles, guild_id)
@doc "A presence by guild id + user id (only populated when `cache_presences: true`)."
@spec presence(String.t(), String.t()) :: {:ok, entity()} | :error
def presence(guild_id, user_id), do: fetch(@presences, {guild_id, user_id})
@spec presence(id(), id()) :: {:ok, entity()} | :error
def presence(guild_id, user_id), do: pair_fetch(@presences, guild_id, user_id)
@doc "Bang variant of `presence/2`."
@spec presence!(String.t(), String.t()) :: entity()
@spec presence!(id(), id()) :: entity()
def presence!(guild_id, user_id), do: unwrap(presence(guild_id, user_id), {guild_id, user_id})
@doc "All cached presences of a guild."
@spec presences(String.t()) :: [entity()]
@spec presences(id()) :: [entity()]
def presences(guild_id), do: prefix_values(@presences, guild_id)
@doc "A voice state by guild id + user id."
@spec voice_state(String.t(), String.t()) :: {:ok, entity()} | :error
def voice_state(guild_id, user_id), do: fetch(@voice_states, {guild_id, user_id})
@spec voice_state(id(), id()) :: {:ok, entity()} | :error
def voice_state(guild_id, user_id), do: pair_fetch(@voice_states, guild_id, user_id)
@doc "Bang variant of `voice_state/2`."
@spec voice_state!(String.t(), String.t()) :: entity()
@spec voice_state!(id(), id()) :: entity()
def voice_state!(guild_id, user_id),
do: unwrap(voice_state(guild_id, user_id), {guild_id, user_id})
@doc "All cached voice states of a guild."
@spec voice_states(String.t()) :: [entity()]
@spec voice_states(id()) :: [entity()]
def voice_states(guild_id), do: prefix_values(@voice_states, guild_id)
# --- read helpers -------------------------------------------------------
@ -440,11 +458,21 @@ defmodule Dexcord.Cache do
end
end
defp pair_fetch(table, guild_id, x_id) do
with {:ok, gid} <- Dexcord.Snowflake.cast(guild_id),
{:ok, xid} <- Dexcord.Snowflake.cast(x_id),
do: fetch(table, {gid, xid})
end
defp all_values(table), do: :ets.select(table, [{{:_, :"$1"}, [], [:"$1"]}])
# Per-guild listing of an ordered_set keyed by `{guild_id, x}`.
defp prefix_values(table, guild_id),
do: :ets.select(table, [{{{guild_id, :_}, :"$1"}, [], [:"$1"]}])
defp prefix_values(table, guild_id) do
case Dexcord.Snowflake.cast(guild_id) do
{:ok, gid} -> :ets.select(table, [{{{gid, :_}, :"$1"}, [], [:"$1"]}])
:error -> []
end
end
defp unwrap({:ok, value}, _key), do: value
defp unwrap(:error, key), do: raise("Dexcord.Cache: no cached entry for #{inspect(key)}")

View file

@ -35,10 +35,15 @@ defmodule Dexcord.Dispatcher do
def handle_cast({:dispatch, name, data}, state) do
config = Dexcord.Config.get()
# Decode the wire payload exactly once, here, and feed the cache BOTH forms:
# the decoded struct drives inserts, the raw partial map drives merges. Decode
# never raises - a failure falls back to the raw map (see `Dexcord.Events`).
decoded = Dexcord.Events.decode(name, data)
# Cache write happens INLINE, in this (single-writer) process, BEFORE the
# handler Task is spawned - so the handler always observes a cache that
# already reflects this event, and cache writes stay in exact gateway order.
Dexcord.Cache.handle_dispatch(name, data, config)
Dexcord.Cache.handle_dispatch(name, decoded, data, config)
maybe_request_members(name, data, config)
maybe_route_slash(name, data, config)

View file

@ -5,6 +5,9 @@ defmodule Dexcord.Model.MemberShared do
def __included_fields__ do
[
{:guild_id, :snowflake, []},
# Cache-populated back-reference to the nested user's id. Decodes `nil` from
# real payloads (no such wire key); `Dexcord.Cache` sets it on write.
{:user_id, :snowflake, []},
{:nick, :string, []},
{:avatar, :string, []},
{:banner, :string, []},

View file

@ -115,7 +115,7 @@ defmodule Dexcord.Prefix do
defp own_message?(author) do
case Dexcord.Cache.me() do
{:ok, %{"id" => id}} -> is_binary(id) and author["id"] == id
{:ok, %Dexcord.User{id: id}} -> Dexcord.Snowflake.cast(author["id"]) == {:ok, id}
_ -> false
end
end

View file

@ -70,11 +70,11 @@ defmodule Dexcord.CacheCascadeTest do
)
# The next dispatch caches fine against the recreated tables - no crash.
guild = %{"id" => "g1", "name" => "Test Guild"}
guild = %{"id" => "1", "name" => "Test Guild"}
Dexcord.Dispatcher.dispatch(:GUILD_CREATE, guild)
wait_until(fn -> match?({:ok, _}, Dexcord.Cache.guild("g1")) end, 200)
assert {:ok, %{"id" => "g1", "name" => "Test Guild"}} = Dexcord.Cache.guild("g1")
wait_until(fn -> match?({:ok, _}, Dexcord.Cache.guild(1)) end, 200)
assert {:ok, %Dexcord.Guild{id: 1, name: "Test Guild"}} = Dexcord.Cache.guild(1)
# The gateway was never restarted (still the same, still alive) - crash contained.
assert Process.whereis(Dexcord.Gateway) == gateway

View file

@ -52,27 +52,27 @@ defmodule Dexcord.CacheIntegrationTest do
await_ready()
guild = %{
"id" => "g100",
"id" => "100",
"name" => "test",
"channels" => [%{"id" => "c100", "type" => 0}],
"roles" => [%{"id" => "r100", "name" => "everyone"}],
"members" => [%{"user" => %{"id" => "u100", "username" => "alice"}, "nick" => "a"}],
"voice_states" => [%{"user_id" => "u100", "channel_id" => "vc100"}],
"presences" => [%{"user" => %{"id" => "u100"}, "status" => "online"}]
"channels" => [%{"id" => "101", "type" => 0}],
"roles" => [%{"id" => "102", "name" => "everyone"}],
"members" => [%{"user" => %{"id" => "103", "username" => "alice"}, "nick" => "a"}],
"voice_states" => [%{"user_id" => "103", "channel_id" => "104"}],
"presences" => [%{"user" => %{"id" => "103"}, "status" => "online"}]
}
FakeGateway.push_dispatch(fake, "GUILD_CREATE", guild, 10)
# The handler observed a cache that ALREADY had the guild (inline write ordering).
assert_receive {:handler_saw, :GUILD_CREATE, {:ok, %{"id" => "g100"}}}, @timeout
assert_receive {:handler_saw, :GUILD_CREATE, {:ok, %Dexcord.Guild{id: 100}}}, @timeout
# And every fan-out table is populated.
assert {:ok, %{"guild_id" => "g100"}} = Cache.channel("c100")
assert {:ok, %{"name" => "everyone"}} = Cache.role("g100", "r100")
assert {:ok, %{"user_id" => "u100"}} = Cache.member("g100", "u100")
assert {:ok, %{"username" => "alice"}} = Cache.user("u100")
assert {:ok, %{"channel_id" => "vc100"}} = Cache.voice_state("g100", "u100")
assert {:ok, %{"status" => "online"}} = Cache.presence("g100", "u100")
# And every fan-out table is populated with decoded structs keyed by integers.
assert {:ok, %Dexcord.TextChannel{guild_id: 100}} = Cache.channel(101)
assert {:ok, %Dexcord.Role{name: "everyone"}} = Cache.role(100, 102)
assert {:ok, %Dexcord.Member{user_id: 103, user: nil}} = Cache.member(100, 103)
assert {:ok, %Dexcord.User{username: "alice"}} = Cache.user(103)
assert {:ok, %Dexcord.VoiceState{channel_id: 104}} = Cache.voice_state(100, 103)
assert {:ok, %Dexcord.Presence{status: "online"}} = Cache.presence(100, 103)
end
test "MESSAGE_CREATE author is cached before the handler observes it" do
@ -80,10 +80,11 @@ defmodule Dexcord.CacheIntegrationTest do
start_bot(fake, intents: :all)
await_ready()
msg = %{"id" => "m1", "author" => %{"id" => "au1", "username" => "bob"}, "content" => "hi"}
msg = %{"id" => "1", "author" => %{"id" => "200", "username" => "bob"}, "content" => "hi"}
FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", msg, 11)
assert_receive {:handler_saw, :MESSAGE_CREATE, {:ok, %{"username" => "bob"}}}, @timeout
assert_receive {:handler_saw, :MESSAGE_CREATE, {:ok, %Dexcord.User{username: "bob"}}},
@timeout
end
test "chunking: GUILD_CREATE -> client sends op 8 -> CHUNK populates members" do
@ -93,21 +94,21 @@ defmodule Dexcord.CacheIntegrationTest do
await_ready()
# A real (non-stub) GUILD_CREATE with no inline members triggers the op 8 request.
FakeGateway.push_dispatch(fake, "GUILD_CREATE", %{"id" => "g200", "name" => "big"}, 20)
FakeGateway.push_dispatch(fake, "GUILD_CREATE", %{"id" => "400", "name" => "big"}, 20)
# The client asks for members (op 8) with the documented query/limit/presences.
assert_receive {:fake_gw, :frame, _c, %{"op" => 8, "d" => d}}, @timeout
assert d["guild_id"] == "g200"
assert d["guild_id"] == "400"
assert d["query"] == ""
assert d["limit"] == 0
assert d["presences"] == false
# The server answers with a chunk; the members flow through dispatch into cache.
chunk = %{
"guild_id" => "g200",
"guild_id" => "400",
"members" => [
%{"user" => %{"id" => "cu1", "username" => "x"}},
%{"user" => %{"id" => "cu2", "username" => "y"}}
%{"user" => %{"id" => "401", "username" => "x"}},
%{"user" => %{"id" => "402", "username" => "y"}}
]
}
@ -116,8 +117,8 @@ defmodule Dexcord.CacheIntegrationTest do
assert_receive {:handler_saw, :GUILD_MEMBERS_CHUNK, members} when length(members) == 2,
@timeout
assert {:ok, _} = Cache.member("g200", "cu1")
assert {:ok, _} = Cache.member("g200", "cu2")
assert {:ok, %Dexcord.Member{}} = Cache.member(400, 401)
assert {:ok, %Dexcord.Member{}} = Cache.member(400, 402)
end
test "no op 8 is sent when request_guild_members is false" do
@ -125,8 +126,8 @@ defmodule Dexcord.CacheIntegrationTest do
start_bot(fake, intents: :all, request_guild_members: false)
await_ready()
FakeGateway.push_dispatch(fake, "GUILD_CREATE", %{"id" => "g300", "name" => "q"}, 30)
assert_receive {:handler_saw, :GUILD_CREATE, {:ok, _}}, @timeout
FakeGateway.push_dispatch(fake, "GUILD_CREATE", %{"id" => "500", "name" => "q"}, 30)
assert_receive {:handler_saw, :GUILD_CREATE, {:ok, %Dexcord.Guild{}}}, @timeout
refute_receive {:fake_gw, :frame, _c, %{"op" => 8}}, 300
end

View file

@ -1,9 +1,10 @@
defmodule Dexcord.CacheTest do
@moduledoc """
Unit tests for `Dexcord.Cache.handle_dispatch/3` against real ETS. Each test
starts a fresh `Dexcord.Cache` (which owns and creates the tables), feeds it
string-keyed payload maps exactly as they arrive off the wire, and asserts table
contents through the public read API.
Unit tests for `Dexcord.Cache.handle_dispatch/4` against real ETS. Each test
starts a fresh `Dexcord.Cache` (which owns and creates the tables), feeds it a
raw string-keyed payload map decoded exactly as the dispatcher would (via
`feed/3`), and asserts table contents through the public read API - which now
returns model structs keyed by integer snowflakes (api-surface.AC2.20).
"""
use ExUnit.Case, async: false
@ -17,341 +18,264 @@ defmodule Dexcord.CacheTest do
:ok
end
defp user(id, name \\ "u"), do: %{"id" => id, "username" => "#{name}#{id}"}
# Feed a raw wire payload exactly as the dispatcher does: decode once, then hand
# the cache both the decoded struct and the raw partial map.
defp feed(name, raw, config \\ @on) do
Cache.handle_dispatch(name, Dexcord.Events.decode(name, raw), raw, config)
end
defp user(id, name \\ "u"), do: %{"id" => to_string(id), "username" => "#{name}#{id}"}
defp member(uid, extra \\ %{}),
do: Map.merge(%{"user" => user(uid), "nick" => "nick#{uid}"}, extra)
# A full-ish guild payload as GUILD_CREATE delivers it.
# A full-ish guild payload as GUILD_CREATE delivers it. Child ids are derived
# from `gid` so a test can reference them: channel `gid+1`, role `gid+2`, member
# user `gid+3`, its voice channel `gid+4`.
defp guild_create(gid, opts \\ []) do
%{
"id" => gid,
"id" => to_string(gid),
"name" => "guild#{gid}",
"emojis" => [%{"id" => "e1", "name" => "smile"}],
"channels" => Keyword.get(opts, :channels, [%{"id" => "c#{gid}", "type" => 0}]),
"emojis" => [%{"id" => to_string(gid + 90), "name" => "smile"}],
"channels" => Keyword.get(opts, :channels, [%{"id" => to_string(gid + 1), "type" => 0}]),
"threads" => Keyword.get(opts, :threads, []),
"roles" => Keyword.get(opts, :roles, [%{"id" => "r#{gid}", "name" => "everyone"}]),
"members" => Keyword.get(opts, :members, [member("m#{gid}")]),
"roles" => Keyword.get(opts, :roles, [%{"id" => to_string(gid + 2), "name" => "everyone"}]),
"members" => Keyword.get(opts, :members, [member(gid + 3)]),
"voice_states" =>
Keyword.get(opts, :voice_states, [%{"user_id" => "m#{gid}", "channel_id" => "vc#{gid}"}]),
Keyword.get(opts, :voice_states, [
%{"user_id" => to_string(gid + 3), "channel_id" => to_string(gid + 4)}
]),
"presences" =>
Keyword.get(opts, :presences, [%{"user" => %{"id" => "m#{gid}"}, "status" => "online"}])
Keyword.get(opts, :presences, [
%{"user" => %{"id" => to_string(gid + 3)}, "status" => "online"}
])
}
end
describe "READY" do
test "stores the bot user and unavailable guild stubs" do
test "stores the bot user (struct) and unavailable guild stubs" do
data = %{
"user" => user("bot1", "self"),
"user" => user(10, "self"),
"guilds" => [
%{"id" => "g1", "unavailable" => true},
%{"id" => "g2", "unavailable" => true}
%{"id" => "1", "unavailable" => true},
%{"id" => "2", "unavailable" => true}
]
}
Cache.handle_dispatch(:READY, data, @off)
feed(:READY, data, @off)
assert {:ok, %{"id" => "bot1"}} = Cache.me()
assert {:ok, %{"unavailable" => true}} = Cache.guild("g1")
assert {:ok, %{"unavailable" => true}} = Cache.guild("g2")
assert {:ok, %Dexcord.User{id: 10}} = Cache.me()
assert {:ok, %Dexcord.UnavailableGuild{id: 1, unavailable: true}} = Cache.guild(1)
assert {:ok, %Dexcord.UnavailableGuild{id: 2}} = Cache.guild(2)
end
end
describe "GUILD_CREATE" do
test "stores guild row with big arrays stripped and fans out children" do
Cache.handle_dispatch(:GUILD_CREATE, guild_create("g1"), @on)
test "stores struct guild row with big arrays stripped and fans out children" do
feed(:GUILD_CREATE, guild_create(100), @on)
{:ok, g} = Cache.guild("g1")
# Big arrays stripped...
for k <- ~w(channels threads roles members presences voice_states) do
refute Map.has_key?(g, k), "expected #{k} stripped from guild row"
{:ok, g} = Cache.guild(100)
assert %Dexcord.Guild{id: 100} = g
# Big arrays stripped to empty...
for f <- [:channels, :threads, :roles, :members, :presences, :voice_states] do
assert Map.get(g, f) == [], "expected #{f} emptied on the guild row"
end
# ...emojis stay inline.
assert [%{"name" => "smile"}] = g["emojis"]
# ...emojis stay inline as decoded structs.
assert [%Dexcord.Emoji{name: "smile"}] = g.emojis
# Fan-out: channel carries an injected guild_id.
assert {:ok, %{"guild_id" => "g1", "type" => 0}} = Cache.channel("cg1")
assert [%{"id" => "cg1"}] = Cache.channels("g1")
# AC2.20: wrote from string-snowflake wire data, read back with INTEGER key.
assert {:ok, %Dexcord.TextChannel{guild_id: 100}} = Cache.channel(101)
assert [%Dexcord.TextChannel{id: 101}] = Cache.channels(100)
assert {:ok, %{"name" => "everyone"}} = Cache.role("g1", "rg1")
assert [%{"id" => "rg1"}] = Cache.roles("g1")
assert {:ok, %Dexcord.Role{name: "everyone"}} = Cache.role(100, 102)
assert [%Dexcord.Role{id: 102}] = Cache.roles(100)
# Member: "user" moved to users table, member keeps "user_id".
{:ok, m} = Cache.member("g1", "mg1")
assert m["user_id"] == "mg1"
refute Map.has_key?(m, "user")
assert {:ok, %{"username" => _}} = Cache.user("mg1")
# Member: nested "user" moved to users table; row keeps user_id, user: nil.
assert {:ok, %Dexcord.Member{user_id: 103, user: nil, nick: "nick103"}} =
Cache.member(100, 103)
assert {:ok, %{"channel_id" => "vcg1"}} = Cache.voice_state("g1", "mg1")
assert {:ok, %{"status" => "online"}} = Cache.presence("g1", "mg1")
assert {:ok, %Dexcord.User{id: 103}} = Cache.user(103)
assert {:ok, %Dexcord.VoiceState{channel_id: 104}} = Cache.voice_state(100, 103)
assert {:ok, %Dexcord.Presence{status: "online"}} = Cache.presence(100, 103)
end
test "presences are skipped when cache_presences is false" do
Cache.handle_dispatch(:GUILD_CREATE, guild_create("g1"), @off)
assert Cache.presence("g1", "mg1") == :error
assert Cache.presences("g1") == []
feed(:GUILD_CREATE, guild_create(100), @off)
assert Cache.presence(100, 103) == :error
assert Cache.presences(100) == []
# But members/voice_states still populate.
assert {:ok, _} = Cache.member("g1", "mg1")
assert {:ok, _} = Cache.voice_state("g1", "mg1")
assert {:ok, %Dexcord.Member{}} = Cache.member(100, 103)
assert {:ok, %Dexcord.VoiceState{}} = Cache.voice_state(100, 103)
end
end
describe "GUILD_UPDATE / GUILD_EMOJIS_UPDATE" do
test "GUILD_UPDATE merges into the existing row without touching child tables" do
Cache.handle_dispatch(:GUILD_CREATE, guild_create("g1"), @on)
Cache.handle_dispatch(:GUILD_UPDATE, %{"id" => "g1", "name" => "renamed"}, @on)
describe "GUILD_UPDATE (Task 1 placeholder)" do
test "stores the updated guild row without touching child tables" do
feed(:GUILD_CREATE, guild_create(100), @on)
feed(:GUILD_UPDATE, %{"id" => "100", "name" => "renamed"}, @on)
{:ok, g} = Cache.guild("g1")
assert g["name"] == "renamed"
assert {:ok, %Dexcord.Guild{name: "renamed"}} = Cache.guild(100)
# Members untouched by a guild update.
assert {:ok, _} = Cache.member("g1", "mg1")
end
test "GUILD_EMOJIS_UPDATE replaces the inline emoji list" do
Cache.handle_dispatch(:GUILD_CREATE, guild_create("g1"), @on)
Cache.handle_dispatch(
:GUILD_EMOJIS_UPDATE,
%{"guild_id" => "g1", "emojis" => [%{"id" => "e9", "name" => "wave"}]},
@on
)
{:ok, g} = Cache.guild("g1")
assert [%{"id" => "e9", "name" => "wave"}] = g["emojis"]
end
end
describe "GUILD_DELETE cascade" do
test "purge wipes exactly that guild's rows across every per-guild table, and no others" do
Cache.handle_dispatch(:GUILD_CREATE, guild_create("g1"), @on)
Cache.handle_dispatch(:GUILD_CREATE, guild_create("g2"), @on)
Cache.handle_dispatch(:GUILD_DELETE, %{"id" => "g1"}, @on)
# g1 gone everywhere.
assert Cache.guild("g1") == :error
assert Cache.members("g1") == []
assert Cache.roles("g1") == []
assert Cache.presences("g1") == []
assert Cache.voice_states("g1") == []
assert Cache.channels("g1") == []
# g2 fully intact.
assert {:ok, _} = Cache.guild("g2")
assert [_] = Cache.members("g2")
assert [_] = Cache.roles("g2")
assert [_] = Cache.presences("g2")
assert [_] = Cache.voice_states("g2")
assert [_] = Cache.channels("g2")
end
test "unavailable:true keeps a stub instead of purging" do
Cache.handle_dispatch(:GUILD_CREATE, guild_create("g1"), @on)
Cache.handle_dispatch(:GUILD_DELETE, %{"id" => "g1", "unavailable" => true}, @on)
assert {:ok, %{"unavailable" => true}} = Cache.guild("g1")
assert {:ok, %Dexcord.Member{}} = Cache.member(100, 103)
end
end
describe "channels and threads" do
test "CHANNEL_CREATE/UPDATE/DELETE and THREAD_* upsert and remove" do
Cache.handle_dispatch(
:CHANNEL_CREATE,
%{"id" => "c1", "guild_id" => "g1", "name" => "gen"},
@off
)
test "CHANNEL_CREATE/UPDATE/DELETE and THREAD_* upsert and remove structs" do
feed(:CHANNEL_CREATE, %{"id" => "1", "type" => 0, "guild_id" => "5", "name" => "gen"}, @off)
assert {:ok, %Dexcord.TextChannel{name: "gen"}} = Cache.channel(1)
assert {:ok, %{"name" => "gen"}} = Cache.channel("c1")
Cache.handle_dispatch(
feed(
:CHANNEL_UPDATE,
%{"id" => "c1", "guild_id" => "g1", "name" => "renamed"},
%{"id" => "1", "type" => 0, "guild_id" => "5", "name" => "renamed"},
@off
)
assert {:ok, %{"name" => "renamed"}} = Cache.channel("c1")
assert {:ok, %Dexcord.TextChannel{name: "renamed"}} = Cache.channel(1)
Cache.handle_dispatch(:THREAD_CREATE, %{"id" => "t1", "guild_id" => "g1"}, @off)
assert {:ok, _} = Cache.channel("t1")
feed(:THREAD_CREATE, %{"id" => "2", "type" => 11, "guild_id" => "5"}, @off)
assert {:ok, %Dexcord.Thread{}} = Cache.channel(2)
Cache.handle_dispatch(:THREAD_DELETE, %{"id" => "t1"}, @off)
assert Cache.channel("t1") == :error
feed(:THREAD_DELETE, %{"id" => "2", "guild_id" => "5"}, @off)
assert Cache.channel(2) == :error
Cache.handle_dispatch(:CHANNEL_DELETE, %{"id" => "c1"}, @off)
assert Cache.channel("c1") == :error
feed(:CHANNEL_DELETE, %{"id" => "1", "type" => 0}, @off)
assert Cache.channel(1) == :error
end
end
describe "threads/1" do
test "returns only thread-typed channels for the given guild" do
test "returns only Thread structs for the given guild" do
g1 =
guild_create("g1",
guild_create(100,
channels: [
%{"id" => "c1", "type" => 0},
%{"id" => "t1", "type" => 10},
%{"id" => "t2", "type" => 11},
%{"id" => "t3", "type" => 12}
%{"id" => "1", "type" => 0},
%{"id" => "2", "type" => 10},
%{"id" => "3", "type" => 11},
%{"id" => "4", "type" => 12}
],
threads: []
)
g2 =
guild_create("g2",
channels: [%{"id" => "c2", "type" => 0}, %{"id" => "t4", "type" => 11}],
guild_create(200,
channels: [%{"id" => "5", "type" => 0}, %{"id" => "6", "type" => 11}],
threads: []
)
Cache.handle_dispatch(:GUILD_CREATE, g1, @off)
Cache.handle_dispatch(:GUILD_CREATE, g2, @off)
feed(:GUILD_CREATE, g1, @off)
feed(:GUILD_CREATE, g2, @off)
assert Cache.threads("g1") |> Enum.map(& &1["id"]) |> Enum.sort() == ["t1", "t2", "t3"]
assert Cache.threads("g2") |> Enum.map(& &1["id"]) == ["t4"]
assert Cache.threads("nope") == []
assert Cache.threads(100) |> Enum.map(& &1.id) |> Enum.sort() == [2, 3, 4]
assert Cache.threads(200) |> Enum.map(& &1.id) == [6]
assert Cache.threads(999) == []
end
end
describe "members" do
test "ADD/UPDATE move user out and merge; REMOVE deletes the member" do
Cache.handle_dispatch(:GUILD_MEMBER_ADD, member("u1", %{"guild_id" => "g1"}), @off)
{:ok, m} = Cache.member("g1", "u1")
assert m["user_id"] == "u1"
assert m["nick"] == "nicku1"
assert {:ok, _} = Cache.user("u1")
test "GUILD_MEMBER_ADD moves user out; GUILD_MEMBER_REMOVE deletes the member" do
feed(:GUILD_MEMBER_ADD, member(1, %{"guild_id" => "5"}), @off)
# Update merges (keeps prior fields, changes nick).
Cache.handle_dispatch(
:GUILD_MEMBER_UPDATE,
%{"guild_id" => "g1", "user" => user("u1"), "nick" => "new"},
@off
)
assert {:ok, %Dexcord.Member{user_id: 1, user: nil, nick: "nick1"}} = Cache.member(5, 1)
assert {:ok, %Dexcord.User{id: 1}} = Cache.user(1)
{:ok, m2} = Cache.member("g1", "u1")
assert m2["nick"] == "new"
Cache.handle_dispatch(
:GUILD_MEMBER_REMOVE,
%{"guild_id" => "g1", "user" => user("u1")},
@off
)
assert Cache.member("g1", "u1") == :error
feed(:GUILD_MEMBER_REMOVE, %{"guild_id" => "5", "user" => user(1)}, @off)
assert Cache.member(5, 1) == :error
# User survives removal from one guild.
assert {:ok, _} = Cache.user("u1")
assert {:ok, %Dexcord.User{id: 1}} = Cache.user(1)
end
test "GUILD_MEMBERS_CHUNK bulk upserts members + users, and presences when present" do
data = %{
"guild_id" => "g1",
"members" => [member("u1"), member("u2")],
"presences" => [%{"user" => %{"id" => "u1"}, "status" => "idle"}]
"guild_id" => "5",
"members" => [member(1), member(2)],
"presences" => [%{"user" => %{"id" => "1"}, "status" => "idle"}]
}
Cache.handle_dispatch(:GUILD_MEMBERS_CHUNK, data, @on)
feed(:GUILD_MEMBERS_CHUNK, data, @on)
assert length(Cache.members("g1")) == 2
assert {:ok, _} = Cache.user("u1")
assert {:ok, _} = Cache.user("u2")
assert {:ok, %{"status" => "idle"}} = Cache.presence("g1", "u1")
assert length(Cache.members(5)) == 2
assert {:ok, %Dexcord.User{}} = Cache.user(1)
assert {:ok, %Dexcord.User{}} = Cache.user(2)
assert {:ok, %Dexcord.Presence{status: "idle"}} = Cache.presence(5, 1)
end
end
describe "roles" do
test "ROLE_CREATE/UPDATE/DELETE" do
Cache.handle_dispatch(
feed(
:GUILD_ROLE_CREATE,
%{"guild_id" => "g1", "role" => %{"id" => "r1", "name" => "mod"}},
%{"guild_id" => "5", "role" => %{"id" => "9", "name" => "mod"}},
@off
)
assert {:ok, %{"name" => "mod"}} = Cache.role("g1", "r1")
assert {:ok, %Dexcord.Role{name: "mod"}} = Cache.role(5, 9)
Cache.handle_dispatch(
feed(
:GUILD_ROLE_UPDATE,
%{"guild_id" => "g1", "role" => %{"id" => "r1", "name" => "admin"}},
%{"guild_id" => "5", "role" => %{"id" => "9", "name" => "admin"}},
@off
)
assert {:ok, %{"name" => "admin"}} = Cache.role("g1", "r1")
assert {:ok, %Dexcord.Role{name: "admin"}} = Cache.role(5, 9)
Cache.handle_dispatch(:GUILD_ROLE_DELETE, %{"guild_id" => "g1", "role_id" => "r1"}, @off)
assert Cache.role("g1", "r1") == :error
feed(:GUILD_ROLE_DELETE, %{"guild_id" => "5", "role_id" => "9"}, @off)
assert Cache.role(5, 9) == :error
end
end
describe "presences toggle" do
test "PRESENCE_UPDATE writes only when cache_presences is true" do
p = %{"guild_id" => "g1", "user" => %{"id" => "u1"}, "status" => "dnd"}
p = %{"guild_id" => "5", "user" => %{"id" => "1"}, "status" => "dnd"}
Cache.handle_dispatch(:PRESENCE_UPDATE, p, @off)
assert Cache.presence("g1", "u1") == :error
feed(:PRESENCE_UPDATE, p, @off)
assert Cache.presence(5, 1) == :error
Cache.handle_dispatch(:PRESENCE_UPDATE, p, @on)
assert {:ok, %{"status" => "dnd"}} = Cache.presence("g1", "u1")
feed(:PRESENCE_UPDATE, p, @on)
assert {:ok, %Dexcord.Presence{status: "dnd"}} = Cache.presence(5, 1)
end
end
describe "voice states" do
test "upsert on channel_id, delete when channel_id is nil" do
Cache.handle_dispatch(
:VOICE_STATE_UPDATE,
%{"guild_id" => "g1", "user_id" => "u1", "channel_id" => "vc1"},
@off
)
feed(:VOICE_STATE_UPDATE, %{"guild_id" => "5", "user_id" => "1", "channel_id" => "7"}, @off)
assert {:ok, %Dexcord.VoiceState{channel_id: 7}} = Cache.voice_state(5, 1)
assert {:ok, %{"channel_id" => "vc1"}} = Cache.voice_state("g1", "u1")
Cache.handle_dispatch(
:VOICE_STATE_UPDATE,
%{"guild_id" => "g1", "user_id" => "u1", "channel_id" => nil},
@off
)
assert Cache.voice_state("g1", "u1") == :error
feed(:VOICE_STATE_UPDATE, %{"guild_id" => "5", "user_id" => "1", "channel_id" => nil}, @off)
assert Cache.voice_state(5, 1) == :error
end
end
describe "USER_UPDATE and MESSAGE_CREATE" do
test "USER_UPDATE merges into me" do
Cache.handle_dispatch(:READY, %{"user" => user("bot1", "self"), "guilds" => []}, @off)
Cache.handle_dispatch(:USER_UPDATE, %{"id" => "bot1", "username" => "renamed"}, @off)
assert {:ok, %{"username" => "renamed"}} = Cache.me()
end
test "MESSAGE_CREATE opportunistically upserts author and member fragment" do
data = %{
"guild_id" => "g1",
"author" => user("a1"),
"member" => %{"nick" => "frag", "roles" => ["r1"]}
}
Cache.handle_dispatch(:MESSAGE_CREATE, data, @off)
assert {:ok, _} = Cache.user("a1")
{:ok, m} = Cache.member("g1", "a1")
assert m["user_id"] == "a1"
assert m["nick"] == "frag"
end
test "MESSAGE_CREATE ignores webhook authors" do
data = %{"webhook_id" => "wh1", "author" => %{"id" => "wh1", "username" => "hook"}}
Cache.handle_dispatch(:MESSAGE_CREATE, data, @off)
assert Cache.user("wh1") == :error
describe "MESSAGE_CREATE (Task 1: author only)" do
test "opportunistically upserts the message author as a struct" do
data = %{"id" => "1", "guild_id" => "5", "author" => user(2), "content" => "hi"}
feed(:MESSAGE_CREATE, data, @off)
assert {:ok, %Dexcord.User{id: 2}} = Cache.user(2)
end
end
describe "read API shapes" do
test "bang variants return the value or raise" do
Cache.handle_dispatch(:GUILD_CREATE, guild_create("g1"), @on)
assert %{"id" => "g1"} = Cache.guild!("g1")
assert_raise RuntimeError, fn -> Cache.guild!("nope") end
assert_raise RuntimeError, fn -> Cache.member!("g1", "nope") end
test "bang variants return the struct or raise" do
feed(:GUILD_CREATE, guild_create(100), @on)
assert %Dexcord.Guild{id: 100} = Cache.guild!(100)
assert_raise RuntimeError, fn -> Cache.guild!(999) end
assert_raise RuntimeError, fn -> Cache.member!(100, 999) end
end
test "listings return plain lists (empty, never :error)" do
assert Cache.guilds() == []
assert Cache.channels("g1") == []
assert Cache.members("g1") == []
assert Cache.channels(5) == []
assert Cache.members(5) == []
end
test "reads accept string and integer ids interchangeably" do
feed(:GUILD_CREATE, guild_create(100), @off)
assert {:ok, %Dexcord.Guild{id: 100}} = Cache.guild(100)
assert {:ok, %Dexcord.Guild{id: 100}} = Cache.guild("100")
# Non-castable ids read as a miss, never a crash.
assert Cache.guild("not-a-snowflake") == :error
end
end
end

View file

@ -74,8 +74,9 @@ defmodule Dexcord.PrefixTest do
end
test "skips the bot's own messages via the cached self id, even without a bot flag" do
Dexcord.Cache.handle_dispatch(:READY, %{"user" => %{"id" => "me-id"}}, %{})
msg = %{"content" => "!ping", "author" => %{"id" => "me-id"}}
raw = %{"user" => %{"id" => "123"}}
Dexcord.Cache.handle_dispatch(:READY, Dexcord.Events.decode(:READY, raw), raw, %{})
msg = %{"content" => "!ping", "author" => %{"id" => "123"}}
assert Prefix.dispatch(msg, prefix: "!", to: Router) == :ignore
refute_received {:routed, _, _, _}
end