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.1] - 2026-09-21
A patch release: bug fixes and a dependency security update. Nothing is removed and no signature
changes. Upgrading needs no configuration change — though a project that added {:rustler, ...} only
because EDA would not compile without it can now drop it, and Rust with it.
Installation
def deps do
[
{:eda, "~> 0.4.1"}
]
endSecurity
- httpoison 3 is accepted, and a fresh project resolves to it. hackney 1.25.0, reached through
httpoison 2, carries EEF-CVE-2026-47069, -47071 (high), -47075 and -47076, and the fixes exist only
from hackney 4.0.1 — which httpoison 2 cannot use. EDA now requires
~> 2.0 or ~> 3.0: a project held on httpoison 2 by another dependency still resolves, and everyone else gets hackney 4. Runmix deps.update httpoison hackneyto move an existing lock
Fixed
- EDA compiled only if you added Rustler yourself.
:rustleris declared optional, but the DAVE NIF module calleduse Rustlerunconditionally, so a project without it failed with module Rustler is not loaded — and adding it meant installing a Rust toolchain, whether or not the bot used voice. EDA now compiles without it, and the NIF functions raise:nif_not_loadedas documented dave: truewithout the NIF no longer crashes the voice session. EDA used to advertise DAVE to Discord and then call a stub that raises. It now advertises DAVE only when the NIF is loaded; otherwise it logs which dependency is missing. Discord has required DAVE for voice outside Stage channels since March 2026, so a bot that joins voice needs Rustler; the README's Voice section says what to add- Connecting to voice without DAVE is announced before Discord refuses it — including in the
default configuration, where
:daveis not set and nothing used to be said. Once per VM, with a link to the README's Voice section - Close code 4017 names its cause. Discord refuses a voice connection that does not offer DAVE with 4017 ("E2EE/DAVE protocol required"). EDA logged it as a generic disconnect; it now logs an error saying DAVE is required and how to enable it
- Message options were silently dropped. The keyword form of every message send —
EDA.API.Message.create/2,edit/3,reply/2,EDA.API.Webhook.execute/3,edit_message/4,EDA.API.Thread.create_post/3— kept five keys and discarded the rest, and still answered{:ok, message}:allowed_mentions, soallowed_mentions: %{parse: []}— the usual guard against user-supplied text pinging@everyone— did nothing, and the ping went out;flags, so a silent message (flags: 4096) notified the channel;message_reference,tts,sticker_ids,nonce,enforce_nonce;- on webhooks,
username,avatar_url,thread_nameandapplied_tags
v2: truecombines with an explicitflags:instead of overwriting it- Webhook
thread_id,waitandwith_componentstravel in the URL, where Discord reads them.thread_idused to be dropped, so a message meant for a thread went to the parent channel;edit_message/4accepts it too EDA.ready?/0goes false while a shard is disconnected, and true again once it resumes or completes a fresh READY. It used to stay true for good after the first READY, so a bot whose gateway had died — including for reasons that never reconnect, such as a revoked token — reported itself ready forever.await_ready/1now waits out an outage. REST keeps working meanwhileEDA.API.Guild.prune/2,EDA.API.Webhook.create/2andmodify/2,EDA.API.Thread.start/2andstart_from_message/3,EDA.API.Channel.edit_permissions/3,EDA.API.User.modify_me/1andEDA.API.Entitlement.create_test/1accept a keyword list; each raised inside the JSON encoder- EDA's own code compiles without warnings on Elixir 1.20 — twenty
size(...)patterns now pin their variables, ahead of that becoming an error, plus an unusedrequireand an unreachable clause - Starting without a token logs one line instead of a nine-line block and a second, redundant warning
Deprecated
- An option a route does not define now logs a warning, and will raise in 0.5. Discord ignores a
field or query parameter it does not recognise, so a misspelt option never failed — it silently did
nothing:
limitmistyped on a member listing returned one member instead of a thousand,user_idmistyped on an entitlement or subscription listing returned everybody's. Every route that takes options now names the ones Discord defines and warns about the rest, naming the function and the accepted keys. The request is still sent unchanged, so nothing that works today stops working
[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