api-surface #1

Merged
luna merged 51 commits from api-surface into mistress 2026-07-05 14:43:13 +00:00
2 changed files with 279 additions and 0 deletions
Showing only changes of commit b9f1ced136 - Show all commits

feat(permissions): documented overwrite-resolution algorithm on Guild

Luna 2026-07-05 08:15:07 -03:00

View file

@ -61,6 +61,132 @@ defmodule Dexcord.Guild do
@doc "The creation `DateTime` encoded in this guild's id, or `:error`."
@spec created_at(t()) :: {:ok, DateTime.t()} | :error
def created_at(%{id: id}), do: Dexcord.Snowflake.to_datetime(id)
import Bitwise
@all_permissions Dexcord.Permissions.all() |> Map.values() |> Enum.reduce(0, &Bitwise.bor/2)
@timeout_allowed Bitwise.bor(
Dexcord.Permissions.all()[:view_channel],
Dexcord.Permissions.all()[:read_message_history]
)
@doc """
Computes a member's effective permissions in the guild (arity 2) or in a
specific channel (arity 3, applying permission overwrites and the timeout
rule).
`guild.roles` must be populated when reading from the cache, set them first:
`%{guild | roles: Dexcord.Cache.roles(guild.id)}`. Returns the raw permissions
integer; combine with `Dexcord.Permissions.has?/2`.
Implements Discord's documented algorithm: owner → all; base = the @everyone
role (`role id == guild id`) OR'd with each member role; `ADMINISTRATOR` → all,
skipping overwrites entirely; otherwise channel overwrites in order
@everyone (deny then allow), aggregated role overwrites (all denies OR'd, all
allows OR'd, deny before allow), then the member overwrite; finally the timeout
rule (a member whose `communication_disabled_until` is in the future keeps only
`VIEW_CHANNEL | READ_MESSAGE_HISTORY`, unless owner/administrator a null or
past value is NOT a timeout).
"""
@spec member_permissions(t(), Dexcord.Member.t(), term()) :: non_neg_integer()
def member_permissions(guild, member, channel \\ nil)
def member_permissions(%{} = guild, member, channel) do
user_id = member_user_id(member)
cond do
guild.owner_id == user_id ->
@all_permissions
true ->
base = base_permissions(guild, member)
if has_flag?(base, :administrator) do
@all_permissions
else
base
|> apply_channel_overwrites(guild, member, user_id, channel)
|> apply_timeout(member)
end
end
end
defp member_user_id(member), do: member.user_id || (member.user && member.user.id)
defp has_flag?(perms, flag), do: Dexcord.Permissions.has?(perms, flag)
# base = @everyone role perms OR'd with each member role's perms (unknown role
# ids contribute nothing).
defp base_permissions(guild, member) do
Enum.reduce(member.roles, role_permissions(guild, guild.id), fn role_id, acc ->
bor(acc, role_permissions(guild, role_id))
end)
end
defp role_permissions(guild, role_id) do
case Enum.find(guild.roles, fn role -> role.id == role_id end) do
nil -> 0
role -> role.permissions || 0
end
end
defp apply_channel_overwrites(perms, _guild, _member, _user_id, nil), do: perms
defp apply_channel_overwrites(perms, guild, member, user_id, channel) do
overwrites = channel.permission_overwrites || []
perms
|> apply_everyone_overwrite(overwrites, guild.id)
|> apply_role_overwrites(overwrites, member.roles)
|> apply_member_overwrite(overwrites, user_id)
end
defp apply_everyone_overwrite(perms, overwrites, guild_id) do
case Enum.find(overwrites, fn ow -> ow.id == guild_id end) do
nil -> perms
ow -> apply_deny_allow(perms, ow.deny, ow.allow)
end
end
# All role overwrites for the member's roles are aggregated: denies OR'd, allows
# OR'd, then applied deny-before-allow as a single tier.
defp apply_role_overwrites(perms, overwrites, member_roles) do
{deny, allow} =
overwrites
|> Enum.filter(fn ow -> ow.id in member_roles end)
|> Enum.reduce({0, 0}, fn ow, {deny, allow} ->
{bor(deny, ow.deny || 0), bor(allow, ow.allow || 0)}
end)
apply_deny_allow(perms, deny, allow)
end
defp apply_member_overwrite(perms, overwrites, user_id) do
case Enum.find(overwrites, fn ow -> ow.id == user_id end) do
nil -> perms
ow -> apply_deny_allow(perms, ow.deny, ow.allow)
end
end
defp apply_deny_allow(perms, deny, allow) do
perms
|> band(bnot(deny || 0))
|> bor(allow || 0)
end
defp apply_timeout(perms, member) do
case member.communication_disabled_until do
%DateTime{} = cdu ->
if DateTime.compare(cdu, DateTime.utc_now()) == :gt do
band(perms, @timeout_allowed)
else
perms
end
_ ->
perms
end
end
end
defmodule Dexcord.UnavailableGuild do

View file

@ -0,0 +1,153 @@
defmodule Dexcord.PermissionsComputeTest do
@moduledoc """
Pure tests for `Dexcord.Guild.member_permissions/2,3` the documented
overwrite-resolution algorithm (owner base roles ADMINISTRATOR short-circuit
@everyone/role/member overwrites timeout rule), verified verbatim.
"""
use ExUnit.Case, async: true
import Bitwise
alias Dexcord.Guild
alias Dexcord.Member
alias Dexcord.Overwrite
alias Dexcord.Permissions
alias Dexcord.Role
@send Permissions.from_list([:send_messages])
@view Permissions.from_list([:view_channel])
@rmh Permissions.from_list([:read_message_history])
@admin Permissions.from_list([:administrator])
@ban Permissions.from_list([:ban_members])
@all Permissions.all() |> Map.values() |> Enum.reduce(0, &bor/2)
@timeout_allowed bor(@view, @rmh)
# Guild id 1; @everyone role has id == guild id (== 1).
defp guild(roles), do: %Guild{id: 1, owner_id: 100, roles: roles}
defp everyone(perms), do: %Role{id: 1, permissions: perms}
defp role(id, perms), do: %Role{id: id, permissions: perms}
defp member(user_id, role_ids), do: %Member{user_id: user_id, roles: role_ids}
test "the guild owner gets all permissions" do
g = guild([everyone(0)])
m = member(100, [])
assert Guild.member_permissions(g, m) == @all
end
test "an ADMINISTRATOR member gets all permissions, even with a deny-everything channel overwrite" do
g = guild([everyone(0), role(20, @admin)])
m = member(200, [20])
channel = %Dexcord.TextChannel{
id: 5,
permission_overwrites: [%Overwrite{id: 1, type: :role, allow: 0, deny: @all}]
}
assert Guild.member_permissions(g, m, channel) == @all
end
test "base permissions are the OR of @everyone and every member role" do
g = guild([everyone(@view), role(10, @send), role(11, @ban)])
m = member(200, [10, 11])
assert Guild.member_permissions(g, m) == (@view ||| @send ||| @ban)
end
test "unknown member role ids are skipped" do
g = guild([everyone(@view), role(10, @send)])
m = member(200, [10, 999])
assert Guild.member_permissions(g, m) == (@view ||| @send)
end
test "an @everyone channel overwrite that denies SEND_MESSAGES removes it" do
g = guild([everyone(@view), role(10, @send)])
m = member(200, [10])
channel = %Dexcord.TextChannel{
id: 5,
permission_overwrites: [%Overwrite{id: 1, type: :role, allow: 0, deny: @send}]
}
result = Guild.member_permissions(g, m, channel)
refute Permissions.has?(result, :send_messages)
assert Permissions.has?(result, :view_channel)
end
test "a role overwrite allow restores a bit denied by @everyone (deny then allow order)" do
g = guild([everyone(@view), role(10, @send)])
m = member(200, [10])
channel = %Dexcord.TextChannel{
id: 5,
permission_overwrites: [
%Overwrite{id: 1, type: :role, allow: 0, deny: @send},
%Overwrite{id: 10, type: :role, allow: @send, deny: 0}
]
}
result = Guild.member_permissions(g, m, channel)
assert Permissions.has?(result, :send_messages)
end
test "a member overwrite deny wins over a role overwrite allow (member applied last)" do
g = guild([everyone(@view), role(10, 0)])
m = member(200, [10])
channel = %Dexcord.TextChannel{
id: 5,
permission_overwrites: [
%Overwrite{id: 10, type: :role, allow: @send, deny: 0},
%Overwrite{id: 200, type: :member, allow: 0, deny: @send}
]
}
result = Guild.member_permissions(g, m, channel)
refute Permissions.has?(result, :send_messages)
end
test "within the role tier, an allow OR-aggregates over a deny of the same bit (allow wins)" do
g = guild([everyone(@view), role(10, 0), role(11, 0)])
m = member(200, [10, 11])
channel = %Dexcord.TextChannel{
id: 5,
permission_overwrites: [
%Overwrite{id: 10, type: :role, allow: 0, deny: @send},
%Overwrite{id: 11, type: :role, allow: @send, deny: 0}
]
}
result = Guild.member_permissions(g, m, channel)
assert Permissions.has?(result, :send_messages)
end
test "a timed-out member keeps exactly VIEW_CHANNEL | READ_MESSAGE_HISTORY" do
future = DateTime.add(DateTime.utc_now(), 3600, :second)
g = guild([everyone(@view ||| @send ||| @rmh)])
m = %Member{user_id: 200, roles: [], communication_disabled_until: future}
assert Guild.member_permissions(g, m) == @timeout_allowed
end
test "a timed-out administrator is unaffected (owner/admin skip the timeout rule)" do
future = DateTime.add(DateTime.utc_now(), 3600, :second)
g = guild([everyone(0), role(20, @admin)])
m = %Member{user_id: 200, roles: [20], communication_disabled_until: future}
assert Guild.member_permissions(g, m) == @all
end
test "a PAST communication_disabled_until is NOT a timeout" do
past = DateTime.add(DateTime.utc_now(), -3600, :second)
g = guild([everyone(@view ||| @send)])
m = %Member{user_id: 200, roles: [], communication_disabled_until: past}
result = Guild.member_permissions(g, m)
assert Permissions.has?(result, :send_messages)
assert Permissions.has?(result, :view_channel)
end
end