api-surface #1
8 changed files with 406 additions and 444 deletions
feat(cache): structs keyed by integer snowflakes; dispatcher decodes once
commit
1c35130775
|
|
@ -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)}")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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, []},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue