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.5.0-beta.2] - 2026-09-22
The second beta of 0.5 fixes what live testing of the first found in voice. Received audio that failed DAVE decryption reached the consumer still encrypted, and a session that stopped decrypting never recovered; two processes on the same token took the voice connection from each other in a loop. Nothing breaks from beta.1.
Installation
def deps do
[
{:eda, "~> 0.5.0-beta.2"}
]
endA requirement of "~> 0.5.0-beta.1" already accepts this beta: mix deps.update eda fetches it.
Added
EDA.Permission.for_member/2computes a member's guild permissions from anEDA.Memberor a raw member map, such as the one an interaction carries. Unlikein_guild/2, the member need not be cached, which without theGUILD_MEMBERSintent most are not.
Fixed
SHARD_READYandALL_SHARDS_READYreport the number of guilds loaded inguild_count, and the startup log line with them. They always said 0: the count was read once every guild had arrived, from the counter of guilds still pending. A shard that times out counts the guilds that did arrive. When the bot is declared ready is unchanged — it already waited for every guild.- A received voice frame that failed DAVE decryption was dispatched as
VOICE_AUDIOanyway, still encrypted, and never counted as a failure: the check for passthrough read the NIF's{:ok, false}as true. Such frames are now dropped, and[:eda, :voice, :dave, :frame_decrypt_error]fires for them as documented. - A DAVE session that stops decrypting now recovers by itself. After 36 frames in a row fail, the last transition is reported to Discord as invalid and the session re-initialised, so the bot is removed from the group and added again — measured at under a second in a live channel. It used to stay broken until the bot left the channel. Failures while a transition is pending are expected and not counted.
- A received RTP packet carrying only padding — clients send them to probe bandwidth — was
dispatched as
VOICE_AUDIOwith emptyopus. It is now ignored. Under DAVE, these packets were every decryption failure seen in a live channel — 10 in 40 s of speech, against none among the 920 frames of audio and silence. - Two processes running on the same token — a deploy overlapping the old instance — took the
voice connection of a guild from each other in a loop once both had joined: each one reacted to
the other's voice state as if it were its own, restarted its session, got a
4006, and rejoined. Measured live: 16 restarts on each side in 30 s. The bot's voice state carries the gateway session that joined, so a process now recognises another's, and the one that joined last keeps the connection; the other stops its session, without disconnecting the new holder, and logs a warning.
[0.5.0-beta.1] - 2026-09-21
The first beta of 0.5: a large minor release. Voice needs neither Rust nor configuration any more, every gateway event Discord documents is a typed struct, and EDA now covers every route of Discord's reference that a bot token can use — modal components, the soundboard, onboarding, the bot's own application, message search, guild administration and more. One change is breaking: an option a route does not define now raises instead of warning.
Installation
def deps do
[
{:eda, "~> 0.5.0-beta.1"}
]
endHex installs a pre-release only for a requirement that names one, as above.
Upgrading from 0.4
- An unknown option now raises
ArgumentErrorand nothing is sent. 0.4.1 already loggedunknown optionfor each such call: a project that saw none of those warnings is unaffected. - DAVE is on whenever its NIF is loaded, and the NIF is now downloaded precompiled. Remove
config :eda, dave: trueif you like, and{:rustler, ...}if you added it only for voice.dave: falsestill turns DAVE off. EDA.File'sspoiler: truekeeps the filename instead of prefixingSPOILER_; code that read the prefix back should useEDA.Attachment.spoiler?/1.EDA.Interaction.resolved_channel/2andresolved_channels/1return%EDA.Channel{}structs. Their fields stay reachable with string keys (channel["name"]).
Added
- Attachment flags —
EDA.Attachment.spoiler?/1,clip?/1,thumbnail?/1,remix?/1,animated?/1, plushas_flag?/2,flag_list/1and the fiveflag_*/0constants.spoiler?/1reads the flag Discord reports rather than guessing from aSPOILER_filename prefix EDA.Attachment.keep/2and the:attachmentsoption on message create and edit — the array Discord uses to decide which attachments a message keeps.keep/2also carries the two fields an edit may change on an attachment that already exists, so a file can be blurred, un-blurred or re-captioned without re-uploading itEDA.Message.edit/2accepts a keyword list, includingattachments: :keep— shorthand for the attachments the struct already holds, which is what stops an upload from wiping themEDA.Attachmentgained thetitle,flags,placeholderandplaceholder_versionfields- JSON error codes
160009(cannot reference without read message history),160010(NSFW channel message reference) and160014(cannot forward a message whose content you cannot read) EDA.Cache.Adapter— the cache storage backend is now a behaviour with swappable implementations, configured withconfig :eda, cache_adapter: .... ShipsEDA.Cache.Adapter.ETS(the default, unchanged behaviour) andEDA.Cache.Adapter.NoOp(stores nothing — useful for memory-constrained or stateless bots)EDA.Cache.Adapter.Mnesia— a cluster-wide cache backend. A bot running several nodes gets one view of the cache: an entity seen by one node's shards is readable from every other node, and a restarting node rejoins a populated cache instead of a cold one. Configurable throughconfig :eda, cache_mnesia: [copies: ..., nodes: ..., wait_timeout: ...].:mnesiastays out of EDA's:extra_applicationson purpose, so bots that never select this adapter never start it- Admission policy, size limits, eviction and telemetry now live above the adapter rather than inside it, so a third-party backend inherits all four for free — a backend only has to answer where an entity lives
EDA.API.Message.search/2andEDA.Message.search/2— search a guild's message history (GET /guilds/{id}/messages/search). Every documented filter is supported, including the multi-valued ones, and options take atoms (has: [:image],sort_by: :relevance) rather than Discord's stringsEDA.Message.search/2flattens what the endpoint returns:messagesis a list of context groups, not of messages, with the match marked"hit" => true. It answers with:results(the matches, as structs),:groups(each match with its neighbours),:total_resultsand:indexing?- An option
search/2does not define is refused rather than forwarded, since Discord ignores a query parameter it does not recognise — a typo would otherwise return the guild's whole history while looking like a filtered search - The filter limits Discord documents are checked before the request —
:limit1–25,:offset≤ 9975,:content≤ 1024 characters, 500 channels, 100 authors — with a message naming the option, rather than an opaque50035 - JSON error code
110000(search index not yet available) - Invite target users — an invite can be restricted to a named list of people.
EDA.API.Invite.target_users/1,update_target_users/2andtarget_users_job_status/1, plus:target_usersoncreate/2. Discord carries the list as a CSV file uploaded asmultipart/form-data; EDA takes and returns plain lists of user ids and handles the file on both sides. The upload is asynchronous, so the job status names its states —:processing,:completed,:failed EDA.API.Invite.get/2—GET /invites/{code}, with:with_countsand:guild_scheduled_event_id. It had no wrapper at all:role_idsonEDA.API.Invite.create/2— roles granted to whoever accepts the invite (requiresMANAGE_ROLES), and:reasononcreate/2anddelete/2EDA.Inviteentity functions —fetch_invite/2,create/2,delete/2,target_users/1,set_target_users/2, all returning structsEDA.Invitegainedtype,flags,roles,expires_at,created_at,guild,channel,target_application,guild_scheduled_eventand the two approximate counts, plustype/1,target_type/1,guest_invite?/1,has_target_users?/1,permanent?/1andurl/1.guild_idandchannel_idare derived from the nested objects when Discord sends the REST shape rather than the gateway one- An option
create/2orget/2does not define is refused rather than forwarded: Discord ignores a body field or query parameter it does not recognise, somax_ages:would quietly produce the default 24-hour invite while looking like it asked for an hour EDA.HTTP.Multipart.encode_named/2, and a matching sender in the internal HTTP client, for endpoints wanting a file under a name of its own rather than thefiles[n]attachment conventionEDA.API.Member.modify_me/2andEDA.Member.modify_me/2— the bot's per-guild profile (PATCH /guilds/{id}/members/@me). Setsnick,avatar,bannerandbiofor one guild, overriding the account-wide identity there. Only:nickneeds a permission (CHANGE_NICKNAME); the appearance fields need noneEDA.ImageData— builds the base64 data URIs Discord calls image data, for every endpoint that takes an avatar, banner, icon or emoji rather than a file upload. The media type is read from the image's magic number instead of its extension, and a format Discord does not accept — WebP above all — is refused locally with a readable message instead of producing an opaque API error:avatarand:banneraccept a path, raw image bytes or a ready-made data URI onEDA.API.Member.modify_me/2andEDA.API.User.modify_me/1;nilclears the fieldEDA.Membergained thebannerandbiofieldsmodify_me/2refuses an option it does not define instead of dropping it —nickname:previously answered{:ok, member}with nothing changed — and enforces the 32-character nickname limit Discord enforces. No bio limit is imposed, because Discord imposes none- Permissions straight off an interaction — a resolved channel carries two bitsets Discord
computed for you:
app_permissionsfor the bot andpermissionsfor the invoking user.EDA.Interaction.app_permissions/1,2,user_permissions/1,2,can?/2,3,user_can?/2,3andpermission_list/1,2read them, so "may I post in the channel they picked?" needs no REST call and no permission arithmetic user_permissions/1anduser_can?/2answer the same question for the invoking user in the channel the interaction came from, which Discord ships on the interaction'smember- An absent bitset is
nil, never0— being told nothing is not the same as being denied everything, and the documentation says when a resolved channel'sapp_permissionsis absent EDA.Membergainedpermissions, the precomputed bitset Discord sends on an interaction's member and whichfrom_raw/1was droppingEDA.Interaction.resolved_channel/2andresolved_channels/1return%EDA.Channel{}structs instead of raw mapsEDA.Channelgainedpermissions,app_permissionsandlast_pin_timestamp— fields Discord sends only on the partial channel objects inside an interactionEDA.Subscription— the monetization endpoints returned raw maps with a status integer and nothing to interpret it.status/1names it:active,:inactiveor:ending, withstatus_value/1,renewing?/1,canceled?/1andchanging_plan?/1over it, plusfetch_subscription/2andlist/2returning structs. Discord renumbered this enum on 2026-06-16 —INACTIVEandENDINGswapped values — so code comparing the raw integer kept running and started meaning the opposite; the moduledoc says soEDA.User.premium_type/1andnitro?/1— names the Nitro tier. The field needs theidentify.premiumOAuth2 scope, so it is absent from every user a bot sees;nilmeans not known and is deliberately a different answer from:noneEDA.Usernow keepspremium_type,mfa_enabled,locale,verified,email,avatar_decoration_dataandcollectibles, whichfrom_raw/1was dropping. On a real guild the last two are set on 129 and 111 of 573 users:file_typesonEDA.Command.Option.attachment/3— up to ten filters narrowing the file picker Discord shows the user, as:image,:video,:audioor a dot-prefixed extensionEDA.FileType— the rules behind those filters, in a module of its own so the File Upload component can share them.normalize!/1validates,expand/1resolves the group names to concrete extensions,matches?/2re-applies the filter to a filename, andequivalent?/2compares two filter lists ignoring order- The filters are validated before Discord sees them:
"pdf"written without its dot, a group name that does not exist, and an eleventh filter are all refused with a message naming the fix. Discord takes the first two without complaint and then shows the user nothing - Modal components — every field Discord now offers in a modal, in
EDA.Modal:label/3(type 18) carries a field's label and description; inside it gotext_field/3, the select menus ofEDA.Component,file_upload/2(19),radio_group/3(21),checkbox_group/3(22) andcheckbox/2(23), withchoice/3for the options of the two groups.EDA.Component.text_display/1can sit between fields. Discord's limits — lengths, option counts, 0–10 files, one default per radio group,min_values: 0only when optional, no disabled component, what may go in a label and what must — are checked when the modal is built, with a message naming the fix instead of an opaque50035 EDA.Modal.modal/3takes a list of components. The positional form andtext_input/4with its action rows keep working; Discord still accepts them but no longer recommends themEDA.Modal.get_values/1reads label-based submissions and returns each value in the shape of its component: a string for a text field, a list for a select, checkbox group or file upload, a string ornilfor a radio group, a boolean for a checkboxEDA.Modal.get_attachments/2— the files a file upload received, asEDA.Attachmentstructs:requiredon every select menu (modal only), and:default_valueson user, role, mentionable and channel selects, to prefill them. A mentionable select takes{:user, id}or{:role, id}, and defaults beyond:max_values— which Discord sets to 1 — are refused- Soundboard —
EDA.API.Soundboardcovers all seven routes:default_sounds/0,list/1,get/2,create/2,modify/3,delete/3, andsend_sound/3, which plays a sound into a voice channel the bot has joined.EDA.SoundboardSoundis the struct, withlist/1,default_sounds/0,fetch_sound/2,play/3— which supplies the source guild of a guild's sound itself —url/1anddefault?/1 EDA.SoundData— builds the MP3 or Ogg data URI a sound is uploaded as.:soundoncreate/2takes a path, raw bytes or a URI; the format is read from the audio's header, not its extension, and a sound over Discord's 512 KiB is refused before it is sent- Soundboard events —
GUILD_SOUNDBOARD_SOUND_CREATE,_UPDATE,_DELETE,GUILD_SOUNDBOARD_SOUNDS_UPDATEandSOUNDBOARD_SOUNDS, as typed structs holdingEDA.SoundboardSounds. They fell through toEDA.Event.Rawbefore EDA.SoundboardSound.request/1— asks the gateway for several guilds' sounds at once (opcode 31), one request per shard, answered by aSOUNDBOARD_SOUNDSevent per guildVOICE_CHANNEL_EFFECT_SEND—EDA.Event.VoiceChannelEffectSend, sent when someone in the bot's voice channel plays a sound or sends an emoji reaction.soundboard?/1tells the two apart, and the animation type is:premiumor:basic- The bot's own application —
EDA.API.Application.me/0andmodify_me/1(GET/PATCH /applications/@me),activity_instance/1, and theEDA.Appstruct withme/0,modify/1,flags/1,has_flag?/2,integration_types/1,icon_url/2andcover_image_url/2. It isEDA.AppbecauseEDA.Applicationis EDA's OTP application EDA.Appreadsflags_newwhen Discord sends it:flagsstops at 31 bits and any newer flag appears only in the string, so code readingflagsalone would miss it without a soundmodify_me/1takes friendly forms and translates them: flags as atoms, permissions asEDA.Permissionatoms,integration_types_configkeyed by:guild_install/:user_install,event_webhooks_statusas:enabled/:disabled, and:icon/:cover_imageas a path or bytes throughEDA.ImageData. What Discord would refuse is refused before sending: a flag other than the three limited intents, more than 5 tags or one over 20 characters- Guild onboarding —
EDA.API.Guild.onboarding/1andmodify_onboarding/2(GET/PUT /guilds/{id}/onboarding), andEDA.Onboardingwith itsPromptandOptionstructs:fetch/1,save/2,prompt/3,option/2,add_prompt/2andremove_prompt/2 - The round trip is safe. Discord returns an option's emoji as an
emojiobject but only acceptsemoji_id/emoji_name/emoji_animated, so saving an onboarding exactly as it was read cleared every emoji — confirmed against the live API. Every request, from structs or from raw maps, now sends the flat fields - New prompts and options get a placeholder id: Discord refuses a prompt without one, then replaces it with its own on save
- Every gateway event Discord documents is now a typed struct. Fourteen fell through to
EDA.Event.Raw:USER_UPDATE;ENTITLEMENT_CREATE/_UPDATE/_DELETEandSUBSCRIPTION_CREATE/_UPDATE/_DELETE, the gateway half of monetization EDA already covered over REST;INTEGRATION_CREATE/_UPDATE/_DELETEandGUILD_INTEGRATIONS_UPDATE;APPLICATION_COMMAND_PERMISSIONS_UPDATE;CHANNEL_INFOandVOICE_CHANNEL_START_TIME_UPDATE - New structs behind them:
EDA.Entitlement(withactive?/2, since an expired entitlement is not deleted),EDA.IntegrationandEDA.Command.Permissions(recognising the@everyoneand all-channels constants) EDA.Channel.request_info/2— opcode 43, Request Channel Info: voice channel statuses and session start times, which are not on the channel object, answered byCHANNEL_INFO. Start times areDateTimes; the live gateway sends them as strings though the reference says integer, and both are read- Fifteen audit log action types: soundboard (130–132), automod quarantine (146), creator monetization (150–151), onboarding (163–167), home settings (190–191), voice channel status (192–193)
EDA.Error.not_found?/1— a 404, or one of Discord's Unknown … codes (10001–10999)EDA.Member.top_role/2andtop_role_position/2— the member's highest role in a guild, ordered as Discord orders the hierarchyEDA.Cache.get_role/2andEDA.Cache.Role.get/2— a role looked up by guild and role id in one read,nilfor a role of another guild- The bot's guilds —
EDA.API.User.guilds/1(GET /users/@me/guilds, with:shard, which Discord requires under large bot sharding since September 2026, and:with_counts),stream_guilds/1past the 200-per-call limit, and leaving a guild withEDA.API.User.leave_guild/1/EDA.Guild.leave/1 - Announcements —
EDA.API.Message.crosspost/2/EDA.Message.crosspost/1publish a message to the channels following it;EDA.API.Channel.follow/3/EDA.Channel.follow/3follow an announcement channel into another - Archived threads —
EDA.API.Thread.list_public_archived/2,list_private_archived/2,list_joined_private_archived/2, andstream_archived/3paging on each kind's own cursor. ADateTimeis accepted for:before - Application emojis —
EDA.API.Emoji.list_application/0,get_application/1,create_application/2(image as a path, bytes or data URI),modify_application/2anddelete_application/1 EDA.API.Voice—regions/0, and Stage voice states:voice_state/2for the bot (:me) or a user, andmodify_voice_state/3to move someone on or off stage, or raise the bot's hand- Guild administration in
EDA.API.Guild:integrations/1anddelete_integration/3;welcome_screen/1andmodify_welcome_screen/2;preview/1;vanity_url/1(the only way to the vanity invite's use count);voice_regions/1;widget_settings/1,modify_widget/2,widget/1andwidget_image_url/2; andmodify_incident_actions/2, which pauses invites or DMs during a raid and refuses a pause over Discord's 24 hours before sending - Command permissions —
EDA.API.Command.permissions/1,2read who may use the app's commands in a guild. Writing them needs a user's OAuth2 token, not a bot's - Role connection metadata —
EDA.API.Application.role_connection_metadata/0andupdate_role_connection_metadata/1, with named types and Discord's limits checked: 5 records, keys ofa-z,0-9and_
Changed
EDA.File's:spoileroption no longer renames the file. It sets the attachment request'sis_spoilerfield instead, so the name you chose is the name Discord shows — previously every spoiler arrived asSPOILER_holiday.png.EDA.File.effective_name/1returns the name unchanged; a prefix you write yourself still worksEDA.HTTP.Multipart.encode/2keeps anattachmentsarray already present in the payload and indexes uploads after it, instead of replacing itEDA.API.Message.forward/3documents that Discord requires read access to the source message's content, and which error says it was refused- Breaking: an option a route does not define now raises
ArgumentError, and nothing is sent. 0.4.1 logged a warning and sent the request unchanged, as announced there. Discord ignores a field or query parameter it does not recognise, so a misspelt option never failed — it silently did nothing, or worse:limitmistyped on a member listing returned one member,user_idmistyped on an entitlement listing returned everybody's, andday: 30on a prune kicked on the default seven days. The error names the function and the accepted keys. A project that saw nounknown optionwarning on 0.4.1 is unaffected. - Voice works without Rust and without configuration. The DAVE NIF is now downloaded precompiled
when EDA compiles — Linux (x86-64, ARM64, ARMv7, RISC-V; glibc and musl), macOS, Windows and
FreeBSD — and checked against checksums shipped in the package. A project that added
{:rustler, ...}for voice can drop it. Building from source remains available withconfig :rustler_precompiled, :force_build, eda: true. If the NIF can be neither downloaded nor built, EDA still compiles, with a warning, and only voice is affected. - DAVE is on by default whenever the NIF is loaded. Discord refuses voice without it outside
Stage channels, so
config :eda, dave: trueis no longer needed;dave: falseturns it off. - The 4017 refusal now says why DAVE was not offered: turned off by
dave: false, or the NIF not loaded.
Fixed
- A string-keyed map — a decoded JSON body, such as
EDA.API.User.modify_me(%{"username" => "x"})— no longer crashes the option check with an opaque "expected a keyword list" error. Every route validating a map body was affected since 0.4.1. A string key now counts as the atom of the same name. - DAVE transitions are followed as the protocol specifies. A downgrade to protocol 0 is acknowledged — before, it went unanswered and the transition stalled — and then sends media unencrypted, as Discord expects; an upgrade restores end-to-end encryption, including for playback already under way.
- A refused MLS commit now recovers the way a refused welcome does, by re-initialising and offering a new key package. Recovery happens once per failure, not once per message.
- Recovery and a new group (epoch 1) re-initialise the MLS session instead of resetting it. A reset left no pending group, so a session that then had to commit failed.
- Joining a call no longer logs
Welcome failedas a warning. A welcome that arrives after the bot joined through its own commit is the expected outcome of a race at join, and is logged at debug. - Opus silence frames are no longer counted as DAVE decryption errors.
- The internal query-string builder expands a list value into repeated keys
(
channel_id=a&channel_id=b), which is how Discord expresses a multi-valued filter.URI.encode_query/1raises on a list, so any endpoint needing one was unreachable EDA.Cache.me/0followsUSER_UPDATE. Renaming the bot or changing its avatar left the cached bot user as it was atREADYfor the rest of the session- An interaction response with files lost its attachment metadata. The
attachmentsarray went at the top of the callback payload, where Discord ignores it, instead of insidedata— so file descriptions and spoilers were dropped. It now goes insidedata - An interaction is never dropped when EDA is at
max_event_concurrency. Discord waits three seconds for an answer and does not redeliver, so a droppedINTERACTION_CREATEwas a failure shown to the user. Interactions still count towards the limit; other events are still dropped EDA.Cache.fetch_member/2caches what it fetches. A member returned by REST carries noguild_id, so the REST fallback never stored it and every call went back to the API- A consumer that
exits orthrows is logged with the event it was handling, like one that raises; only exceptions were caught
Security
- The DAVE NIF now builds against
davey0.1.4 instead of 0.1.1. The older lock pulled in OpenMLS and cryptography crates with published advisories — GHSA-8x3w-qj7j-gqhf (high), GHSA-435g-fcv3-8j26 and GHSA-g433-pq76-6cmf — and 0.1.4 also brings the library's encryption in line with Discord's reference implementation. Only projects that compile the NIF (voice withdave: true) are affected; they pick this up on the next build.
Documentation
EDA.User.display_name/1explains why it does not read Discord's owndisplay_namefield: that field is null whenever the user has no global name — 46 of 573 users on a real guild — while this falls back to the username and always names somebodyEDA.FileTypestates that this filter matches the filename's extension and never inspects the file, so it is not validation — Discord's own wording is that you remain responsible for checking the contents- It also notes that Discord returns the filters in an order of its own, which makes a direct
list comparison report "changed" forever when diffing a deployed command against its
definition;
equivalent?/2is the comparison to use EDA.User.premium_type/1:0is only meaningful for an app approved foridentify.premium; Discord answers0to every other app, so:nonethere says nothing about the user's NitroEDA.Event.PresenceUpdate: a custom status is omitted when the user's profile privacy hides it, so its absence does not mean there is noneEDA.API.Guild.prune/2andprune_count/2: thePRUNE_REQUIRES_ADMINguild feature makes them requireADMINISTRATOR
[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