api-surface #1
2 changed files with 248 additions and 0 deletions
feat(events): decode table covering all 67 documented dispatch events
commit
e1b6c01e72
139
lib/dexcord/events.ex
Normal file
139
lib/dexcord/events.ex
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
defmodule Dexcord.Events do
|
||||
@moduledoc """
|
||||
The gateway event decode table: maps every documented dispatch event atom
|
||||
to its payload type and decodes raw payloads exactly once, in the
|
||||
dispatcher. Decoding NEVER raises: failures log a warning (event name
|
||||
only — payloads may be huge or sensitive) and fall back to the raw map.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
@full_object %{
|
||||
GUILD_CREATE: Dexcord.Guild,
|
||||
GUILD_UPDATE: Dexcord.Guild,
|
||||
GUILD_DELETE: Dexcord.UnavailableGuild,
|
||||
CHANNEL_CREATE: Dexcord.Channel,
|
||||
CHANNEL_UPDATE: Dexcord.Channel,
|
||||
CHANNEL_DELETE: Dexcord.Channel,
|
||||
THREAD_CREATE: Dexcord.Channel,
|
||||
THREAD_UPDATE: Dexcord.Channel,
|
||||
THREAD_MEMBER_UPDATE: Dexcord.ThreadMember,
|
||||
MESSAGE_CREATE: Dexcord.Message,
|
||||
MESSAGE_UPDATE: Dexcord.Message,
|
||||
GUILD_MEMBER_ADD: Dexcord.Member,
|
||||
GUILD_AUDIT_LOG_ENTRY_CREATE: Dexcord.AuditLogEntry,
|
||||
USER_UPDATE: Dexcord.User,
|
||||
INTERACTION_CREATE: Dexcord.Interaction,
|
||||
APPLICATION_COMMAND_PERMISSIONS_UPDATE: Dexcord.GuildApplicationCommandPermissions,
|
||||
AUTO_MODERATION_RULE_CREATE: Dexcord.AutoModerationRule,
|
||||
AUTO_MODERATION_RULE_UPDATE: Dexcord.AutoModerationRule,
|
||||
AUTO_MODERATION_RULE_DELETE: Dexcord.AutoModerationRule,
|
||||
GUILD_SCHEDULED_EVENT_CREATE: Dexcord.GuildScheduledEvent,
|
||||
GUILD_SCHEDULED_EVENT_UPDATE: Dexcord.GuildScheduledEvent,
|
||||
GUILD_SCHEDULED_EVENT_DELETE: Dexcord.GuildScheduledEvent,
|
||||
INTEGRATION_CREATE: Dexcord.Integration,
|
||||
INTEGRATION_UPDATE: Dexcord.Integration,
|
||||
VOICE_STATE_UPDATE: Dexcord.VoiceState,
|
||||
PRESENCE_UPDATE: Dexcord.Presence
|
||||
}
|
||||
|
||||
@envelopes %{
|
||||
READY: Dexcord.Events.Ready,
|
||||
AUTO_MODERATION_ACTION_EXECUTION: Dexcord.Events.AutoModerationActionExecution,
|
||||
CHANNEL_PINS_UPDATE: Dexcord.Events.ChannelPinsUpdate,
|
||||
THREAD_DELETE: Dexcord.Events.ThreadDelete,
|
||||
THREAD_LIST_SYNC: Dexcord.Events.ThreadListSync,
|
||||
THREAD_MEMBERS_UPDATE: Dexcord.Events.ThreadMembersUpdate,
|
||||
GUILD_BAN_ADD: Dexcord.Events.GuildBanAdd,
|
||||
GUILD_BAN_REMOVE: Dexcord.Events.GuildBanRemove,
|
||||
GUILD_EMOJIS_UPDATE: Dexcord.Events.GuildEmojisUpdate,
|
||||
GUILD_STICKERS_UPDATE: Dexcord.Events.GuildStickersUpdate,
|
||||
GUILD_INTEGRATIONS_UPDATE: Dexcord.Events.GuildIntegrationsUpdate,
|
||||
GUILD_MEMBER_REMOVE: Dexcord.Events.GuildMemberRemove,
|
||||
GUILD_MEMBER_UPDATE: Dexcord.Events.GuildMemberUpdate,
|
||||
GUILD_MEMBERS_CHUNK: Dexcord.Events.GuildMembersChunk,
|
||||
GUILD_ROLE_CREATE: Dexcord.Events.GuildRoleCreate,
|
||||
GUILD_ROLE_UPDATE: Dexcord.Events.GuildRoleUpdate,
|
||||
GUILD_ROLE_DELETE: Dexcord.Events.GuildRoleDelete,
|
||||
GUILD_SCHEDULED_EVENT_USER_ADD: Dexcord.Events.GuildScheduledEventUserAdd,
|
||||
GUILD_SCHEDULED_EVENT_USER_REMOVE: Dexcord.Events.GuildScheduledEventUserRemove,
|
||||
INTEGRATION_DELETE: Dexcord.Events.IntegrationDelete,
|
||||
INVITE_CREATE: Dexcord.Events.InviteCreate,
|
||||
INVITE_DELETE: Dexcord.Events.InviteDelete,
|
||||
MESSAGE_DELETE: Dexcord.Events.MessageDelete,
|
||||
MESSAGE_DELETE_BULK: Dexcord.Events.MessageDeleteBulk,
|
||||
MESSAGE_REACTION_ADD: Dexcord.Events.ReactionAdd,
|
||||
MESSAGE_REACTION_REMOVE: Dexcord.Events.ReactionRemove,
|
||||
MESSAGE_REACTION_REMOVE_ALL: Dexcord.Events.ReactionRemoveAll,
|
||||
MESSAGE_REACTION_REMOVE_EMOJI: Dexcord.Events.ReactionRemoveEmoji,
|
||||
MESSAGE_POLL_VOTE_ADD: Dexcord.Events.PollVoteAdd,
|
||||
MESSAGE_POLL_VOTE_REMOVE: Dexcord.Events.PollVoteRemove,
|
||||
TYPING_START: Dexcord.Events.TypingStart,
|
||||
VOICE_CHANNEL_EFFECT_SEND: Dexcord.Events.VoiceChannelEffectSend,
|
||||
VOICE_SERVER_UPDATE: Dexcord.Events.VoiceServerUpdate,
|
||||
WEBHOOKS_UPDATE: Dexcord.Events.WebhooksUpdate
|
||||
}
|
||||
|
||||
# Documented raw passthrough: no-payload markers and out-of-scope groups
|
||||
# (monetization, stage instances) whose consumers use the raw map.
|
||||
@passthrough [
|
||||
:RESUMED,
|
||||
:ENTITLEMENT_CREATE,
|
||||
:ENTITLEMENT_UPDATE,
|
||||
:ENTITLEMENT_DELETE,
|
||||
:STAGE_INSTANCE_CREATE,
|
||||
:STAGE_INSTANCE_UPDATE,
|
||||
:STAGE_INSTANCE_DELETE
|
||||
]
|
||||
|
||||
@doc "Payload classification for an event atom (used by tests and docs)."
|
||||
@spec payload_type(atom()) ::
|
||||
{:full, module()} | {:envelope, module()} | :passthrough | :unknown
|
||||
def payload_type(name) do
|
||||
cond do
|
||||
is_map_key(@full_object, name) -> {:full, @full_object[name]}
|
||||
is_map_key(@envelopes, name) -> {:envelope, @envelopes[name]}
|
||||
name in @passthrough -> :passthrough
|
||||
true -> :unknown
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Decodes a raw dispatch payload for `name`. Returns the decoded payload,
|
||||
or the raw payload unchanged for passthrough/unknown events, or — if
|
||||
decoding fails or produces nil — the raw payload (with a warning logged).
|
||||
"""
|
||||
@spec decode(atom() | {:unknown_event, String.t()}, term()) :: term()
|
||||
def decode({:unknown_event, _name}, raw), do: raw
|
||||
|
||||
def decode(name, raw) when is_atom(name) do
|
||||
case payload_type(name) do
|
||||
{:full, mod} -> safe_decode(mod, name, raw)
|
||||
{:envelope, mod} -> safe_decode(mod, name, raw)
|
||||
:passthrough -> raw
|
||||
:unknown -> raw
|
||||
end
|
||||
end
|
||||
|
||||
defp safe_decode(mod, name, raw) do
|
||||
case mod.from_map(raw) do
|
||||
nil ->
|
||||
Logger.warning("Dexcord.Events: #{name} payload decoded to nil; delivering raw map")
|
||||
raw
|
||||
|
||||
decoded ->
|
||||
decoded
|
||||
end
|
||||
rescue
|
||||
# Deliberately untestable defensive backstop. Under the Phase 1 tolerance
|
||||
# contract `from_map/1` never raises — a non-map decodes to nil and hits the
|
||||
# nil→raw fallback above (which IS tested). This rescue exists only to keep
|
||||
# dispatch alive if a future decoder bug ever violates that contract.
|
||||
error ->
|
||||
Logger.warning(
|
||||
"Dexcord.Events: decode failed for #{name} (#{inspect(error.__struct__)}); delivering raw map"
|
||||
)
|
||||
|
||||
raw
|
||||
end
|
||||
end
|
||||
109
test/dexcord/events_test.exs
Normal file
109
test/dexcord/events_test.exs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
defmodule Dexcord.EventsTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
import ExUnit.CaptureLog
|
||||
|
||||
alias Dexcord.Events
|
||||
|
||||
describe "api-surface.AC2.17: every documented event is classified" do
|
||||
test "no atom in EventNames.all/0 has payload_type :unknown" do
|
||||
for name <- Dexcord.EventNames.all() do
|
||||
assert Events.payload_type(name) != :unknown,
|
||||
"#{name} is unclassified in Dexcord.Events"
|
||||
end
|
||||
end
|
||||
|
||||
test "the three-way classification partitions exactly EventNames.all/0" do
|
||||
# Recompute the counts independently of the table internals by classifying
|
||||
# every documented name and tallying — adding a new event name without
|
||||
# classifying it makes some name :unknown and fails the AC2.17 test above;
|
||||
# this asserts the totals sum to the whitelist size with no overlap.
|
||||
names = Dexcord.EventNames.all()
|
||||
|
||||
grouped =
|
||||
Enum.group_by(names, fn name ->
|
||||
case Events.payload_type(name) do
|
||||
{:full, _} -> :full
|
||||
{:envelope, _} -> :envelope
|
||||
:passthrough -> :passthrough
|
||||
:unknown -> :unknown
|
||||
end
|
||||
end)
|
||||
|
||||
full = Map.get(grouped, :full, [])
|
||||
envelope = Map.get(grouped, :envelope, [])
|
||||
passthrough = Map.get(grouped, :passthrough, [])
|
||||
unknown = Map.get(grouped, :unknown, [])
|
||||
|
||||
assert unknown == []
|
||||
assert length(full) + length(envelope) + length(passthrough) == length(names)
|
||||
assert length(names) == 67
|
||||
end
|
||||
end
|
||||
|
||||
describe "payload_type/1 classification" do
|
||||
test "full-object events" do
|
||||
assert Events.payload_type(:MESSAGE_CREATE) == {:full, Dexcord.Message}
|
||||
assert Events.payload_type(:GUILD_DELETE) == {:full, Dexcord.UnavailableGuild}
|
||||
assert Events.payload_type(:INTERACTION_CREATE) == {:full, Dexcord.Interaction}
|
||||
end
|
||||
|
||||
test "envelope events" do
|
||||
assert Events.payload_type(:MESSAGE_DELETE) == {:envelope, Dexcord.Events.MessageDelete}
|
||||
assert Events.payload_type(:READY) == {:envelope, Dexcord.Events.Ready}
|
||||
end
|
||||
|
||||
test "passthrough events" do
|
||||
assert Events.payload_type(:RESUMED) == :passthrough
|
||||
assert Events.payload_type(:ENTITLEMENT_CREATE) == :passthrough
|
||||
assert Events.payload_type(:STAGE_INSTANCE_UPDATE) == :passthrough
|
||||
end
|
||||
end
|
||||
|
||||
describe "decode/2 smoke tests" do
|
||||
test "MESSAGE_CREATE decodes a full message struct" do
|
||||
raw = Dexcord.Fixtures.load!("message_create_guild.json")
|
||||
assert %Dexcord.Message{} = Events.decode(:MESSAGE_CREATE, raw)
|
||||
end
|
||||
|
||||
test "MESSAGE_DELETE decodes to its envelope" do
|
||||
decoded = Events.decode(:MESSAGE_DELETE, %{"id" => "1", "channel_id" => "2"})
|
||||
assert %Dexcord.Events.MessageDelete{id: 1, channel_id: 2} = decoded
|
||||
end
|
||||
|
||||
test "RESUMED passes the raw map through untouched" do
|
||||
assert Events.decode(:RESUMED, %{}) == %{}
|
||||
assert Events.decode(:RESUMED, %{"trace" => ["x"]}) == %{"trace" => ["x"]}
|
||||
end
|
||||
|
||||
test "unknown_event passes the raw map through" do
|
||||
assert Events.decode({:unknown_event, "WEIRD"}, %{"x" => 1}) == %{"x" => 1}
|
||||
end
|
||||
end
|
||||
|
||||
describe "GUILD_DELETE semantics" do
|
||||
test "removed-from-guild (no unavailable key) -> unavailable: false" do
|
||||
decoded = Events.decode(:GUILD_DELETE, %{"id" => "1"})
|
||||
assert %Dexcord.UnavailableGuild{id: 1, unavailable: false} = decoded
|
||||
end
|
||||
|
||||
test "outage (unavailable: true) -> unavailable: true" do
|
||||
decoded = Events.decode(:GUILD_DELETE, %{"id" => "1", "unavailable" => true})
|
||||
assert %Dexcord.UnavailableGuild{id: 1, unavailable: true} = decoded
|
||||
end
|
||||
end
|
||||
|
||||
describe "nil -> raw fallback" do
|
||||
test "a non-map payload decodes to nil and falls back to the raw payload, logging the event name only" do
|
||||
log =
|
||||
capture_log(fn ->
|
||||
assert Events.decode(:MESSAGE_CREATE, "not a map") == "not a map"
|
||||
end)
|
||||
|
||||
# The warning names the event...
|
||||
assert log =~ "MESSAGE_CREATE"
|
||||
# ...but never leaks the payload content.
|
||||
refute log =~ "not a map"
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue