All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
[0.4.0] - 2026-09-19
Installation
def deps do
[
{:eda, "~> 0.4.0"}
]
endAdded
Permission classification —
EDA.Permission.channel_types/1,guild_only?/1,channel?/1,applies_to?/2andinapplicable/2. The table is generated from Discord's own Bitwise Permission Flags reference rather than transcribed, so the twelve guild-level permissions and the text/voice/ stage split come from the source.inapplicable/2lists the permissions in a bitset that have no effect in a given channel — Discord acceptsKICK_MEMBERSin a channel overwrite and silently ignores it. JDA classifies permissions but offers no such check; Nostrum does neitherEDA.Permission.explain/3— returns how a member's channel permissions were derived, not just the result::baserole permissions, the ordered:steps(each overwrite tier with the:allow/:denyit applied and the running result), which:gatesfired, and:denied_bynaming the gate that reduced the result to zero. Neither JDA nor Nostrum exposes the derivationEDA.Member.timed_out?/1andtime_out_end/1— mirroring JDA'sisTimedOut()/getTimeOutEnd(). Discord leavescommunication_disabled_untilpopulated after expiry, sotime_out_end/1may return a past date whiletimed_out?/1answersfalse. Both accept a struct or a raw member mapEDA.API.Role.member_counts/1—GET /guilds/{id}/roles/member-counts, returning a map of role ID to member count, mirroring discord.js'sguild.roles.fetchMemberCounts(). The@everyonerole is absent from the result (every member carries it), and the counts overlap — a member with three roles is counted in all three, so they sum to more than the guild'smember_count. Unlike counting fromEDA.Cache.members/1, it needs neither the privileged:guild_membersintent nor a chunked cache, and it includes roles with zero members, which a cache-derived tally omits entirelyEDA.Role.colors/EDA.Role.Colors— role colours, superseding the deprecated singlecolorfield. Three styles, matching JDA'sisDefault/isGradient/isHolographicand discord.js'sRoleColors:style/1returns:default,:solid,:gradientor:holographic, withdefault?/1,solid?/1,gradient?/1andholographic?/1.gradient?/1is false for holographic roles — Discord treats them as distinct styles.EDA.Role.primary_color/1preferscolors.primary_colorand falls back tocolorprimary_color: 0means no colour, not black — it is:default, as in JDA'sisDefault(). Measured across 201 roles on 8 real guilds, 78 were in that state (39%), only 8 of them@everyone, so treating them as solid would mis-colour most of a guild's rolesWriting colours —
EDA.API.Role.set_colors/4andEDA.Role.set_colors/4, with theEDA.Role.Colors.solid/1,gradient/2andholographic/0constructors. Only thecolorsobject is sent, never the deprecatedcolor, matching discord.js.EDA.Role.ColorsimplementsJason.Encoderandto_map/1omits nil keys so a solid colour does not accidentally clear a gradientHolographic is a constrained style: sending
tertiary_colormakes Discord enforce11127295 / 16759788 / 16761760, soholographic/0takes no arguments and the three values are exposed asholographic_primary/0,holographic_secondary/0andholographic_tertiary/0EDA.Error.missing_guild_feature/0(670006) — returned as HTTP 403 when setting a gradient or holographic colour on an ineligible guildObserved on real guilds: every role carried all three keys,
primary_colorequalled the legacycoloron all 57, and 7 used a gradient.ENHANCED_ROLE_COLORSappeared in thefeaturesarray of no guild — not even the one with gradients already in place — so it cannot be used as a pre-check for either reading or writing. Writing on a boost-tier-0 guild returned 670006EDA.User.primary_guild/EDA.User.PrimaryGuild— the user's server tag (identity_guild_id,identity_enabled,tag,badge).EDA.User.server_tag/1returns the tag only when it is actually displayed, andEDA.User.PrimaryGuild.badge_url/2builds the CDN URL.identity_enabledis tri-state:trueshown,falseremoved by the user,nilcleared by Discord —displayed?/1handles all threeconfig :eda, capabilities:— opt into gateway capabilities before Discord makes them mandatory. Accepts a list of atoms, a single atom, or a raw bitfield so future capabilities need no library update. Unset by default, in which case nocapabilitiesfield is sent and IDENTIFY is unchanged. SeeEDA.Gateway.Capabilities:channel_obfuscation(1 <<< 15) — opts into Discord's redaction of channels the bot cannot see, which becomes mandatory for every bot on 2026-11-16. Obfuscated channels still arrive over the gateway but withnameset to"___hidden___", sensitive fields nulled, a single@everyoneVIEW_CHANNELdeny inpermission_overwrites, andCHANNEL_OBFUSCATED(1 <<< 17) inflags. Enabling it early lets a bot observe and handle the change ahead of the deadline. Note that only gateway payloads are redacted: probed against a real guild on 2026-09-19,GET /guilds/{id}/channelsstill returned every obfuscated channel in full, contrary to Discord's changelogChannel flags on
EDA.Channel—flag_pinned/0,flag_require_tag/0,flag_hide_media_download_options/0,flag_obfuscated/0,flag_spoiler/0, plusall_flags/0,has_flag?/2,flag_list/1andobfuscated_name/0.has_flag?/2andflag_list/1accept a%EDA.Channel{}, a raw channel map as the cache stores it, a bare bitfield ornilEDA.Channel.obfuscated?/1— whether Discord redacted a channel because the bot cannot view it. Such channels still arrive over the gateway withnameset to"___hidden___"and a single synthetic@everyoneVIEW_CHANNELdeny inpermission_overwrites; do not compute permissions from those overwrites
Changed
EDA.Permission.in_channel/3now accounts for member timeouts. Discord removes every permission exceptVIEW_CHANNELandREAD_MESSAGE_HISTORYfrom a timed-out member; EDA was reporting them as able to send messages, so a bot gating an action onhas_permission?/4would wrongly allow a silenced member. Neither Nostrum nor JDA applies this — JDA documents the rule but leaves enforcement to the caller. The gate restricts and never grants, and guild owners and administrators are exempt, since Discord refuses to time them out at allEDA.Permission.in_channel/3returns{:error, :channel_obfuscated}for a channel Discord has redacted. The synthetic@everyoneVIEW_CHANNELdeny is indistinguishable from a real overwrite, so computing from it would return a confident but meaningless answer; the ambiguity is surfaced to the caller instead.has_permission?/4maps it tofalse, like any other error- Redaction nulls the sensitive fields rather than omitting them, so a channel that becomes
obfuscated has its cached
name,topic,statusandlast_message_idgenuinely replaced by the merge inEDA.Cache.Channel.update/2— previously cached values do not survive. Redaction is selective:position,parent_id,nsfw,bitrateandrate_limit_per_userkeep real values. Verified against a live guild - Obfuscated channels are kept in the cache rather than dropped: "this channel exists and I
cannot see it" is real information, and Discord expects apps that manage channels or compute
permissions across a guild to detect that state.
EDA.Cache.channels/0andchannels_for_guild/1therefore include them — reject withEDA.Channel.obfuscated?/1, or skip them at admission with achannels:cache policy. Documented onEDA.Cache
[0.3.0] - 2026-09-19
Installation
def deps do
[
{:eda, "~> 0.3.0"}
]
endAdded
- EDA.API.Message.pins/2 — one page of a channel's pins, with
pinned_attimestamps andhas_more - EDA.API.Channel.set_voice_status/3 / EDA.Channel.set_voice_status/3 — set or clear a voice channel's status, plus the
VOICE_CHANNEL_STATUS_UPDATEevent and the:statusfield onEDA.Channel - EDA.Event.RateLimited — gateway rate limit notifications (
opcode,retry_after,guild_id,nonce), previously swallowed byEDA.Event.Raw :reason(audit log) onEDA.API.Message.pin/3andunpin/3, and onEDA.Message.pin/2andunpin/2
Changed
- Pins now use
/channels/{id}/messages/pins; the deprecated/channels/{id}/pinsroutes are gone.EDA.API.Message.pinned/2keeps returning{:ok, [message]}but paginates past the old 50-pin ceiling - EDA.Gateway.MemberChunker throttles all-members OP 8 requests to one per guild per 30 seconds, queueing them instead of letting Discord drop them. Prefix searches and
user_idslookups are exempt. Configurable withconfig :eda, member_chunk_cooldown_ms: 30_000
Fixed
- Cache admission policies are now consulted once, with the real entity. On the REST fallback path a custom
fn/3or module policy was asked about the whole result withnilfor both key and value, and then asked again per entity by the cache module. A policy that pattern matched on a map raisedFunctionClauseError, and per-role decisions were impossible.EDA.Cache.fetch_role/2and the otherfetch_*fallbacks now delegate straight to the cache modules, which apply the policy with the real key and value EDA.Voice.Dave.Nativespecs now match what the NIF actually returns. The Rust side returnsResult<T, Atom>, which Rustler encodes as{:ok, T}, sonew_session/3yields{:ok, reference()}andcreate_key_package/1,process_proposals/4,encrypt_opus/2anddecrypt_audio/3are double wrapped as{:ok, {:ok, binary()}}. The getters (ready?/1,get_epoch/1,status/1,can_passthrough?/2,protocol_version/1) are wrapped too. Runtime behaviour is unchanged —EDA.Voice.Dave.Manageralready normalised both shapes — but the published specs were misleading for anyone calling the NIF directlyEDA.Modal.modal/7declared every input asmap()whilemodal/3..6default the trailing inputs tonil, so each defaulted arity broke its own contract- Removed unreachable clauses from private helpers in
EDA.CacheandEDA.Command.Option
Documentation
- Corrected the Gateway and Cache configuration examples in the README: the key is
gateway_encoding:(notencoding:) and:etfis the default;compress:does not exist since zlib-stream is unconditional; shard overrides use a top-levelshards:(:auto, an integer, or{range, total}) rather thanshard_count:under:gateway; and:cacheoptions are per entity, with noevict_interval EDA.Gateway.Intentsno longer claims:nonprivilegedis the default —EDA.intents/0falls back to[:guilds]
[0.2.0] - 2026-04-04
Installation
def deps do
[
{:eda, "~> 0.2.0"}
]
endAdded
- EDA.Collector — Discord.js-style
awaitpatterns for messages, reactions, components with filters, timeouts, and max count (#4) - EDA.AutoDelete — Timer-based auto-deletion using BEAM timer wheel.
delete_after:option onMessage.create,Interaction.respond,Interaction.followup - EDA.Color — 30 named colors with individual functions, crypto-random generation (
Color.random()), hex parsing,:randomsupport inEmbed.color/2 - EDA.Mention — User, channel, role, emoji, and timestamp formatting helpers
- EDA.OAuth2 — Bot invite URL generator with permission atoms and scopes
- EDA.API.SKU / Entitlement / Subscription — Monetization API endpoints (beta)
- Webhook —
get_message/3,edit_message/4,delete_message/3,wait: trueoption onexecute/3 - Thread —
remove_member/2,get_member/2,list_members/1 - Message —
forward/3(message forwarding),reply/2(auto message_reference) - Command/Option —
localize/3for multi-language name/description translations - Component —
disable_all/1to recursively disable all buttons/selects - Embed —
error/1andsuccess/1pre-styled presets - Interaction —
selected_values/1,component_type/1,delete_source/1,defer_and_edit/3 - Member —
move_voice/3helper - Guild —
icon_url/1CDN helper - EDA.latency/1 — Gateway heartbeat latency shortcut
- EDA.await_message/2, await_reaction/2, await_component/2 — Convenience wrappers
Fixed
- Ban.create —
reason:now sent asX-Audit-Log-Reasonheader instead of JSON body - Modal.get_values — handles atom keys from parsed interaction structs
- Component.section — raises if
:accessoryis missing (Discord requires it) - parse_error — includes Discord's
errorsfield for validation details (50035) - Cache.me() — returns
%EDA.User{}struct instead of raw map - File.from_binary — accepts byte lists for NIF
Vec<u8>compatibility - User helpers —
mention/1,avatar_url/1,display_name/1,bot?/1accept raw maps from cache - ReadyTracker test — stabilized flaky test with synchronous state reset
- Interaction.respond —
delete_after:schedules deletion via interaction token - Interaction.delete_source — works on ephemeral messages (uses type 6 DEFERRED_UPDATE_MESSAGE)
[0.1.3] - 2026-03-07
Installation
def deps do
[
{:eda, "~> 0.1.3"}
]
endAdded
- Voice — FFmpeg volume passthrough for voice playback (
play/3withvolume:option) (#2 by @christomitov)
Fixed
- DAVE — Fail closed on media encryption errors — stops playback cleanly instead of sending undecryptable raw Opus when DAVE encryption fails (#3 by @christomitov)
Acknowledgments
Thanks to @christomitov for both contributions in this release.
[0.1.2] - 2026-03-05
Installation
def deps do
[
{:eda, "~> 0.1.2"}
]
endRequires Rust toolchain for the DAVE NIF (auto-compiled via Rustler on first mix compile).
Fixed
- DAVE — Track
connected_clientsvia OP 11/12/13 (CLIENTS_CONNECT/CLIENTS_DISCONNECT) and pass user IDs toprocess_proposals— MLS group now forms correctly in multi-user channels (#2) - DAVE — OP 24
epoch=1sole member reset — reset MLS group and send new key package so the group reforms when someone rejoins - DAVE — OP 13
client_disconnecthandler per DAVE spec (singleuser_idformat), in addition to batch OP 12
Added
- DAVE —
DirtyCpuscheduling on 6 crypto NIFs (encrypt_opus,decrypt_audio,process_proposals,process_commit,process_welcome,create_key_package) — no longer blocks the BEAM scheduler (#6) - DAVE —
can_passthrough?/2NIF + fallback indecrypt_frame— audio passes through during epoch transitions instead of being dropped (#4) - DAVE — New NIFs:
reinit/4,status/1,protocol_version/1,max_protocol_version/0for fulldaveyAPI coverage (#5)
Acknowledgments
Built on the solid DAVE foundation from #1 by @christomitov. The improvements in this release build on top of that work — connected_clients tracking, DirtyCpu scheduling, passthrough fallback, and additional NIF bindings are all new code layered on the original PR's binary frame routing, key package flow, and error handling.
[0.1.1] - 2026-03-05
Fixed
- DAVE — Binary frame routing for OP 25/27/29/30, proper welcome/commit handling, improved error normalization (#1 by @christomitov)
- Voice — Better reconnection handling, ETS playback progress table lifecycle, encrypt error differentiation (
:not_readyvs:encryption_failed)
[0.1.0] - 2026-02-18
Added
- Gateway — WebSocket connection with automatic reconnection, heartbeat, resume, zlib compression, and ETF/JSON encoding
- Sharding — Shard manager with automatic shard count, member chunking, and per-shard ready tracking
- REST API — Resource-based API modules (Guild, Channel, Message, Member, Role, Emoji, Sticker, Webhook, Invite, Interaction, etc.) with full rate limiting
- Cache — ETS-backed cache for guilds, channels, users, members, roles, presences, and voice states with configurable eviction policies
- Voice — Voice connection with Opus audio sending/receiving, OGG file playback, AES-256-GCM and XChaCha20-Poly1305 encryption
- DAVE — Discord Audio/Video E2EE (experimental) via Rust NIF for MLS-based key ratcheting
- Events — Typed event structs for all Discord gateway events with
EDA.Consumercallback pattern - Entities — Struct-based models for all Discord objects (Guild, Channel, Message, Member, Role, User, etc.) with
Accessbehaviour - Interactions — Slash commands, components, modals, and autocomplete support
- Permissions — Bitfield-based permission calculations with channel overwrite resolution
- Telemetry — Built-in telemetry events for gateway, HTTP, and cache operations