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"}
  ]
end

Added

  • Permission classification — EDA.Permission.channel_types/1, guild_only?/1, channel?/1, applies_to?/2 and inapplicable/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/2 lists the permissions in a bitset that have no effect in a given channel — Discord accepts KICK_MEMBERS in a channel overwrite and silently ignores it. JDA classifies permissions but offers no such check; Nostrum does neither

  • EDA.Permission.explain/3 — returns how a member's channel permissions were derived, not just the result: :base role permissions, the ordered :steps (each overwrite tier with the :allow/:deny it applied and the running result), which :gates fired, and :denied_by naming the gate that reduced the result to zero. Neither JDA nor Nostrum exposes the derivation

  • EDA.Member.timed_out?/1 and time_out_end/1 — mirroring JDA's isTimedOut() / getTimeOutEnd(). Discord leaves communication_disabled_until populated after expiry, so time_out_end/1 may return a past date while timed_out?/1 answers false. Both accept a struct or a raw member map

  • EDA.API.Role.member_counts/1 — GET /guilds/{id}/roles/member-counts, returning a map of role ID to member count, mirroring discord.js's guild.roles.fetchMemberCounts(). The @everyone role 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's member_count. Unlike counting from EDA.Cache.members/1, it needs neither the privileged :guild_members intent nor a chunked cache, and it includes roles with zero members, which a cache-derived tally omits entirely

  • EDA.Role.colors / EDA.Role.Colors — role colours, superseding the deprecated single color field. Three styles, matching JDA's isDefault/isGradient/isHolographic and discord.js's RoleColors: style/1 returns :default, :solid, :gradient or :holographic, with default?/1, solid?/1, gradient?/1 and holographic?/1. gradient?/1 is false for holographic roles — Discord treats them as distinct styles. EDA.Role.primary_color/1 prefers colors.primary_color and falls back to color

  • primary_color: 0 means no colour, not black — it is :default, as in JDA's isDefault(). 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 roles

  • Writing colours — EDA.API.Role.set_colors/4 and EDA.Role.set_colors/4, with the EDA.Role.Colors.solid/1, gradient/2 and holographic/0 constructors. Only the colors object is sent, never the deprecated color, matching discord.js. EDA.Role.Colors implements Jason.Encoder and to_map/1 omits nil keys so a solid colour does not accidentally clear a gradient

  • Holographic is a constrained style: sending tertiary_color makes Discord enforce 11127295 / 16759788 / 16761760, so holographic/0 takes no arguments and the three values are exposed as holographic_primary/0, holographic_secondary/0 and holographic_tertiary/0

  • EDA.Error.missing_guild_feature/0 (670006) — returned as HTTP 403 when setting a gradient or holographic colour on an ineligible guild

  • Observed on real guilds: every role carried all three keys, primary_color equalled the legacy color on all 57, and 7 used a gradient. ENHANCED_ROLE_COLORS appeared in the features array 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 670006

  • EDA.User.primary_guild / EDA.User.PrimaryGuild — the user's server tag (identity_guild_id, identity_enabled, tag, badge). EDA.User.server_tag/1 returns the tag only when it is actually displayed, and EDA.User.PrimaryGuild.badge_url/2 builds the CDN URL. identity_enabled is tri-state: true shown, false removed by the user, nil cleared by Discord — displayed?/1 handles all three

  • config :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 no capabilities field is sent and IDENTIFY is unchanged. See EDA.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 with name set to "___hidden___", sensitive fields nulled, a single @everyone VIEW_CHANNEL deny in permission_overwrites, and CHANNEL_OBFUSCATED (1 <<< 17) in flags. 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}/channels still returned every obfuscated channel in full, contrary to Discord's changelog

  • Channel flags on EDA.Channel — flag_pinned/0, flag_require_tag/0, flag_hide_media_download_options/0, flag_obfuscated/0, flag_spoiler/0, plus all_flags/0, has_flag?/2, flag_list/1 and obfuscated_name/0. has_flag?/2 and flag_list/1 accept a %EDA.Channel{}, a raw channel map as the cache stores it, a bare bitfield or nil

  • EDA.Channel.obfuscated?/1 — whether Discord redacted a channel because the bot cannot view it. Such channels still arrive over the gateway with name set to "___hidden___" and a single synthetic @everyone VIEW_CHANNEL deny in permission_overwrites; do not compute permissions from those overwrites

Changed

  • EDA.Permission.in_channel/3 now accounts for member timeouts. Discord removes every permission except VIEW_CHANNEL and READ_MESSAGE_HISTORY from a timed-out member; EDA was reporting them as able to send messages, so a bot gating an action on has_permission?/4 would 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 all
  • EDA.Permission.in_channel/3 returns {:error, :channel_obfuscated} for a channel Discord has redacted. The synthetic @everyone VIEW_CHANNEL deny 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?/4 maps it to false, like any other error
  • Redaction nulls the sensitive fields rather than omitting them, so a channel that becomes obfuscated has its cached name, topic, status and last_message_id genuinely replaced by the merge in EDA.Cache.Channel.update/2 — previously cached values do not survive. Redaction is selective: position, parent_id, nsfw, bitrate and rate_limit_per_user keep 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/0 and channels_for_guild/1 therefore include them — reject with EDA.Channel.obfuscated?/1, or skip them at admission with a channels: cache policy. Documented on EDA.Cache

[0.3.0] - 2026-09-19

Installation

def deps do
  [
    {:eda, "~> 0.3.0"}
  ]
end

Added

  • EDA.API.Message.pins/2 — one page of a channel's pins, with pinned_at timestamps and has_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_UPDATE event and the :status field on EDA.Channel
  • EDA.Event.RateLimited — gateway rate limit notifications (opcode, retry_after, guild_id, nonce), previously swallowed by EDA.Event.Raw
  • :reason (audit log) on EDA.API.Message.pin/3 and unpin/3, and on EDA.Message.pin/2 and unpin/2

Changed

  • Pins now use /channels/{id}/messages/pins; the deprecated /channels/{id}/pins routes are gone. EDA.API.Message.pinned/2 keeps 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_ids lookups are exempt. Configurable with config :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/3 or module policy was asked about the whole result with nil for both key and value, and then asked again per entity by the cache module. A policy that pattern matched on a map raised FunctionClauseError, and per-role decisions were impossible. EDA.Cache.fetch_role/2 and the other fetch_* fallbacks now delegate straight to the cache modules, which apply the policy with the real key and value
  • EDA.Voice.Dave.Native specs now match what the NIF actually returns. The Rust side returns Result<T, Atom>, which Rustler encodes as {:ok, T}, so new_session/3 yields {:ok, reference()} and create_key_package/1, process_proposals/4, encrypt_opus/2 and decrypt_audio/3 are 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.Manager already normalised both shapes — but the published specs were misleading for anyone calling the NIF directly
  • EDA.Modal.modal/7 declared every input as map() while modal/3..6 default the trailing inputs to nil, so each defaulted arity broke its own contract
  • Removed unreachable clauses from private helpers in EDA.Cache and EDA.Command.Option

Documentation

  • Corrected the Gateway and Cache configuration examples in the README: the key is gateway_encoding: (not encoding:) and :etf is the default; compress: does not exist since zlib-stream is unconditional; shard overrides use a top-level shards: (:auto, an integer, or {range, total}) rather than shard_count: under :gateway; and :cache options are per entity, with no evict_interval
  • EDA.Gateway.Intents no longer claims :nonprivileged is the default — EDA.intents/0 falls back to [:guilds]

[0.2.0] - 2026-04-04

Installation

def deps do
  [
    {:eda, "~> 0.2.0"}
  ]
end

Added

  • EDA.Collector — Discord.js-style await patterns for messages, reactions, components with filters, timeouts, and max count (#4)
  • EDA.AutoDelete — Timer-based auto-deletion using BEAM timer wheel. delete_after: option on Message.create, Interaction.respond, Interaction.followup
  • EDA.Color — 30 named colors with individual functions, crypto-random generation (Color.random()), hex parsing, :random support in Embed.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: true option on execute/3
  • Thread — remove_member/2, get_member/2, list_members/1
  • Message — forward/3 (message forwarding), reply/2 (auto message_reference)
  • Command/Option — localize/3 for multi-language name/description translations
  • Component — disable_all/1 to recursively disable all buttons/selects
  • Embed — error/1 and success/1 pre-styled presets
  • Interaction — selected_values/1, component_type/1, delete_source/1, defer_and_edit/3
  • Member — move_voice/3 helper
  • Guild — icon_url/1 CDN 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 as X-Audit-Log-Reason header instead of JSON body
  • Modal.get_values — handles atom keys from parsed interaction structs
  • Component.section — raises if :accessory is missing (Discord requires it)
  • parse_error — includes Discord's errors field 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?/1 accept 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"}
  ]
end

Added

  • Voice — FFmpeg volume passthrough for voice playback (play/3 with volume: 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"}
  ]
end

Requires Rust toolchain for the DAVE NIF (auto-compiled via Rustler on first mix compile).

Fixed

  • DAVE — Track connected_clients via OP 11/12/13 (CLIENTS_CONNECT/CLIENTS_DISCONNECT) and pass user IDs to process_proposals — MLS group now forms correctly in multi-user channels (#2)
  • DAVE — OP 24 epoch=1 sole member reset — reset MLS group and send new key package so the group reforms when someone rejoins
  • DAVE — OP 13 client_disconnect handler per DAVE spec (single user_id format), in addition to batch OP 12

Added

  • DAVE — DirtyCpu scheduling 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?/2 NIF + fallback in decrypt_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/0 for full davey API 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_ready vs :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.Consumer callback pattern
  • Entities — Struct-based models for all Discord objects (Guild, Channel, Message, Member, Role, User, etc.) with Access behaviour
  • 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