EDA.Permission (EDA v0.5.0-beta.2)

Copy Markdown View Source

Discord permission flags and calculator.

Computes effective permissions for a member at guild or channel level, following Discord's official algorithm with the 3-tier overwrite cascade.

Features

  • Correct 3-tier overwrite cascade: @everyone → roles (merged) → member
  • Access gates: returns 0 if VIEW_CHANNEL is missing, or if VOICE_CONNECT is missing on voice/stage channels
  • has_permission?/3: one-call convenience for permission checks
  • Pure bitwise hot path: no atom-list conversion during calculation
  • All 50+ Discord permissions up to date (bit 52)
  • Nil-safe: returns {:error, reason} instead of crashing on missing data

Usage

# Check if a member can manage messages in a channel
EDA.Permission.has_permission?(guild_id, user_id, channel_id, :manage_messages)

# Get all effective permissions in a channel
{:ok, bitset} = EDA.Permission.in_channel(guild_id, user_id, channel_id)
perms = EDA.Permission.to_list(bitset)

# Guild-level permissions
{:ok, bitset} = EDA.Permission.in_guild(guild_id, user_id)

Summary

Types

A channel category a permission can apply to.

A full permission derivation, as returned by explain/3.

A step in a permission derivation.

Functions

Returns the bitset with all permissions set.

Returns all known permission flag atoms.

Returns true if the permission applies to the given channel.

Returns true for a permission that can meaningfully appear in a channel overwrite.

The channel kinds a permission applies to.

Explains how a member's channel permissions were derived.

Computes effective guild-level permissions for a member already in hand: an EDA.Member, or a raw member map such as the one an interaction carries.

Returns the flag atom for a bit value, or :error.

Returns true for a permission that only has meaning at guild level.

Checks if a specific flag is set in a bitset.

Checks if a member has a specific permission at guild level.

Checks if a member has a specific permission in a channel.

Computes effective channel-level permissions for a member.

Computes effective guild-level permissions for a member.

Lists the permissions in a bitset that have no effect in the given channel.

Returns the bit value for a permission flag.

Converts a list of flag atoms to a combined bitset.

Converts a bitset to a list of flag atoms. Unknown bits are skipped.

Types

bitset()

@type bitset() :: non_neg_integer()

channel_kind()

@type channel_kind() :: :text | :voice | :stage

A channel category a permission can apply to.

explanation()

@type explanation() :: %{
  effective: bitset(),
  base: bitset(),
  steps: [step()],
  gates: [atom()],
  denied_by: atom() | nil
}

A full permission derivation, as returned by explain/3.

flag()

@type flag() ::
  :create_instant_invite
  | :kick_members
  | :ban_members
  | :administrator
  | :manage_channels
  | :manage_guild
  | :add_reactions
  | :view_audit_log
  | :priority_speaker
  | :stream
  | :view_channel
  | :send_messages
  | :send_tts_messages
  | :manage_messages
  | :embed_links
  | :attach_files
  | :read_message_history
  | :mention_everyone
  | :use_external_emojis
  | :view_guild_insights
  | :connect
  | :speak
  | :mute_members
  | :deafen_members
  | :move_members
  | :use_vad
  | :change_nickname
  | :manage_nicknames
  | :manage_roles
  | :manage_webhooks
  | :manage_guild_expressions
  | :use_application_commands
  | :request_to_speak
  | :manage_events
  | :manage_threads
  | :create_public_threads
  | :create_private_threads
  | :use_external_stickers
  | :send_messages_in_threads
  | :use_embedded_activities
  | :moderate_members
  | :view_creator_monetization_analytics
  | :use_soundboard
  | :create_guild_expressions
  | :create_events
  | :use_external_sounds
  | :send_voice_messages
  | :set_voice_channel_status
  | :send_polls
  | :use_external_apps
  | :pin_messages
  | :bypass_slowmode

step()

@type step() :: %{
  :stage => atom(),
  :result => bitset(),
  optional(:allow) => bitset(),
  optional(:deny) => bitset(),
  optional(:gate) => atom()
}

A step in a permission derivation.

:stage is :owner, :administrator, :role_base, :everyone_overwrite, :role_overwrites, :member_overwrite or :gate. Overwrite stages carry the :allow and :deny bitsets that were applied; gate stages carry :gate. :result is the running permission bitset after that step.

Functions

all()

@spec all() :: bitset()

Returns the bitset with all permissions set.

all_flags()

@spec all_flags() :: [flag()]

Returns all known permission flag atoms.

applies_to?(flag, kind)

@spec applies_to?(flag(), channel_kind() | integer() | map()) :: boolean()

Returns true if the permission applies to the given channel.

The second argument is a channel kind (:text, :voice, :stage), a raw Discord channel type integer, or a channel struct or map. Categories accept every kind, since their overwrites cascade to children of any type.

Examples

iex> EDA.Permission.applies_to?(:request_to_speak, :stage)
true

iex> EDA.Permission.applies_to?(:request_to_speak, :text)
false

iex> EDA.Permission.applies_to?(:kick_members, :text)
false

channel?(flag)

@spec channel?(flag()) :: boolean()

Returns true for a permission that can meaningfully appear in a channel overwrite.

Examples

iex> EDA.Permission.channel?(:send_messages)
true

iex> EDA.Permission.channel?(:administrator)
false

channel_types(flag)

@spec channel_types(flag()) :: [channel_kind()]

The channel kinds a permission applies to.

An empty list means the permission is guild-level only — setting it in a channel overwrite has no effect.

Examples

iex> EDA.Permission.channel_types(:send_messages)
[:text, :voice, :stage]

iex> EDA.Permission.channel_types(:kick_members)
[]

iex> EDA.Permission.channel_types(:request_to_speak)
[:stage]

explain(guild_id, user_id, channel_id)

@spec explain(String.t(), String.t(), String.t()) ::
  {:ok, explanation()} | {:error, term()}

Explains how a member's channel permissions were derived.

in_channel/3 answers what a member may do; this answers why. Neither JDA nor Nostrum exposes the derivation, and "why can't my bot post here" is usually answered by guesswork against an opaque integer.

Returns the same :effective bitset as in_channel/3, plus:

  • :base — guild-level permissions from the member's roles, before overwrites;
  • :steps — the derivation in order, each with the running :result. Overwrite steps carry the :allow/:deny bitsets that were applied;
  • :gates — which access gates fired (:timed_out, :no_view_channel, :no_connect);
  • :denied_by — the gate that reduced the result to zero, or nil.

Owner and administrator short-circuit to every permission, and say so in a single step.

Examples

{:ok, why} = EDA.Permission.explain(guild_id, user_id, channel_id)

why.denied_by
#=> :no_view_channel

Enum.map(why.steps, & &1.stage)
#=> [:role_base, :everyone_overwrite, :role_overwrites, :member_overwrite, :gate]

# what the @everyone overwrite took away
why.steps
|> Enum.find(&(&1.stage == :everyone_overwrite))
|> Map.fetch!(:deny)
|> EDA.Permission.to_list()
#=> [:send_messages]

for_member(member, guild_id)

@spec for_member(EDA.Member.t() | map(), String.t() | integer()) ::
  {:ok, bitset()} | {:error, term()}

Computes effective guild-level permissions for a member already in hand: an EDA.Member, or a raw member map such as the one an interaction carries.

Unlike in_guild/2, the member need not be cached — without the GUILD_MEMBERS intent, most are not. Only the guild and its roles are read from the cache.

For the channel an interaction was used in, Discord already sends the member's permissions there, overwrites included, as member.permissions.

Returns {:ok, bitset} or {:error, reason}.

{:ok, perms} = EDA.Permission.for_member(interaction.member, interaction.guild_id)
EDA.Permission.has?(perms, :ban_members)

from_bit(bit)

@spec from_bit(bitset()) :: {:ok, flag()} | :error

Returns the flag atom for a bit value, or :error.

guild_only?(flag)

@spec guild_only?(flag()) :: boolean()

Returns true for a permission that only has meaning at guild level.

Examples

iex> EDA.Permission.guild_only?(:kick_members)
true

iex> EDA.Permission.guild_only?(:send_messages)
false

has?(bitset, flag)

@spec has?(bitset(), flag()) :: boolean()

Checks if a specific flag is set in a bitset.

has_guild_permission?(guild_id, user_id, permission)

@spec has_guild_permission?(String.t(), String.t(), flag()) :: boolean()

Checks if a member has a specific permission at guild level.

has_permission?(guild_id, user_id, channel_id, permission)

@spec has_permission?(String.t(), String.t(), String.t(), flag()) :: boolean()

Checks if a member has a specific permission in a channel.

Convenience function — most common use case for bots.

in_channel(guild_id, user_id, channel_id)

@spec in_channel(String.t(), String.t(), String.t()) ::
  {:ok, bitset()} | {:error, term()}

Computes effective channel-level permissions for a member.

Returns {:ok, bitset} or {:error, reason}.

Algorithm (matches Discord's official spec + JDA)

  1. Owner → ALL_PERMISSIONS
  2. Compute guild base permissions
  3. ADMINISTRATOR → ALL_PERMISSIONS (skips all overwrites)
  4. Apply 3-tier overwrite cascade: a. @everyone role overwrite b. All role overwrites (merged via OR, then applied) c. Member-specific overwrite (highest priority)
  5. Access gate: no VIEW_CHANNEL → 0
  6. Access gate: voice/stage channel + no CONNECT → 0

Obfuscated channels

Returns {:error, :channel_obfuscated} for a channel Discord has redacted because the bot cannot view it (see EDA.Channel.obfuscated?/1). Such a channel carries a single synthetic overwrite denying VIEW_CHANNEL to @everyone, which is indistinguishable from a real one — computing from it would return a confident but meaningless answer, so the ambiguity is surfaced to the caller instead.

has_permission?/4 maps this to false, like any other error.

in_guild(guild_id, user_id)

@spec in_guild(String.t(), String.t()) :: {:ok, bitset()} | {:error, term()}

Computes effective guild-level permissions for a member.

Returns {:ok, bitset} or {:error, reason}.

Algorithm

  1. Guild owner → ALL_PERMISSIONS
  2. OR all role permission bits together
  3. If ADMINISTRATOR is set → ALL_PERMISSIONS

inapplicable(bitset, channel)

@spec inapplicable(bitset(), channel_kind() | integer() | map()) :: [flag()]

Lists the permissions in a bitset that have no effect in the given channel.

Use it to catch a meaningless overwrite before sending it — Discord accepts KICK_MEMBERS in a channel overwrite and silently ignores it. Neither JDA nor Nostrum offers this check.

Examples

iex> bitset = EDA.Permission.to_bitset([:send_messages, :kick_members])
iex> EDA.Permission.inapplicable(bitset, :text)
[:kick_members]

iex> EDA.Permission.inapplicable(EDA.Permission.to_bitset([:send_messages]), :text)
[]

to_bit(flag)

@spec to_bit(flag()) :: bitset()

Returns the bit value for a permission flag.

to_bitset(flags)

@spec to_bitset([flag()]) :: bitset()

Converts a list of flag atoms to a combined bitset.

to_list(bitset)

@spec to_list(bitset()) :: [flag()]

Converts a bitset to a list of flag atoms. Unknown bits are skipped.