api-surface #1

Merged
luna merged 51 commits from api-surface into mistress 2026-07-05 14:43:13 +00:00
2 changed files with 248 additions and 0 deletions
Showing only changes of commit e1b6c01e72 - Show all commits

feat(events): decode table covering all 67 documented dispatch events

Luna 2026-07-05 04:53:12 -03:00

139
lib/dexcord/events.ex Normal file
View 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

View 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