Discord: channel permission math for radio bots (#225 S1)
New pure module app/internal/discord_perms.py:
- effective_permissions(guild, member, channel) -> (bits, trace), in
Discord's order: owner, base (@everyone | member roles), ADMINISTRATOR,
@everyone overwrite, role overwrites (all denies then all allows),
member overwrite, no View Channel = 0, no Connect drops voice perms
(as discord.py does, so labels match the edge), timeout keeps only
View + Read History.
- trace names what removed each radio permission, e.g. "denied by
#dispatch's override for @Muted".
- check_voice(bits, channel, trace=None) -> {ok, is_voice, missing,
reasons, user_limit, limit_bypass}; only type-2 channels count; labels
match node-26#38's _join_refusal ("View Channel", "Connect", "Speak").
- Permission strings parsed with int (values above 2**53 tested);
malformed values raise ValueError so callers treat them as unknown.
No caller yet; S3 wires it into pick_token.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c4e1f9aa61
commit
09746c467b
@@ -0,0 +1,285 @@
|
||||
"""
|
||||
Discord channel permission math for radio bots (server-26#225, slice S1).
|
||||
|
||||
Pure functions, no I/O. Input is the raw JSON Discord's REST API returns:
|
||||
|
||||
guild GET /guilds/{g} {id, owner_id, roles: [{id, name, permissions}]}
|
||||
member GET /guilds/{g}/members/{bot_id} {user: {id}, roles: [role_id], communication_disabled_until}
|
||||
channel GET /guilds/{g}/channels (one) {id, name, type, user_limit, permission_overwrites:
|
||||
[{id, type, allow, deny}]}
|
||||
|
||||
``effective_permissions`` follows Discord's documented order
|
||||
(https://discord.com/developers/docs/topics/permissions#permission-overwrites):
|
||||
|
||||
1. The guild owner has every permission.
|
||||
2. Base = @everyone's permissions OR every role the member has.
|
||||
ADMINISTRATOR there means every permission.
|
||||
3. The channel's @everyone overwrite (deny, then allow).
|
||||
4. The member's role overwrites: OR every deny, OR every allow, then apply
|
||||
deny before allow, so a role that allows beats a role that denies.
|
||||
5. The member's own overwrite, last.
|
||||
6. No VIEW_CHANNEL in the channel means no permission there at all.
|
||||
No CONNECT in a voice channel also takes the voice permissions (Speak,
|
||||
etc.) — discord.py applies the same rule, so the edge's ``_join_refusal``
|
||||
(node-26#38) reports the same labels.
|
||||
7. A timed-out member keeps only View Channel and Read Message History.
|
||||
|
||||
It also returns a ``trace``: for each radio permission the bot ends up
|
||||
without, a sentence naming what removed it ("denied by #dispatch's override
|
||||
for @Muted"), so the customer is told exactly which Discord toggle to fix.
|
||||
|
||||
Permission integers are strings in the API and can exceed 2**53; they are
|
||||
parsed with ``int`` and never pass through a float.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
VIEW_CHANNEL = 1 << 10
|
||||
READ_MESSAGE_HISTORY = 1 << 16
|
||||
CONNECT = 1 << 20
|
||||
SPEAK = 1 << 21
|
||||
MOVE_MEMBERS = 1 << 24
|
||||
ADMINISTRATOR = 1 << 3
|
||||
|
||||
# Every bit Discord defines today fits well under 64; owner/admin get them all.
|
||||
ALL_PERMISSIONS = (1 << 64) - 1
|
||||
|
||||
# What a timed-out member keeps (Discord: "timed out members ... only View
|
||||
# Channel and Read Message History").
|
||||
TIMEOUT_MASK = VIEW_CHANNEL | READ_MESSAGE_HISTORY
|
||||
|
||||
# Permissions that only mean something to someone connected to voice; lost
|
||||
# with Connect (discord.py ``Permissions.voice()``: connect, speak, mute,
|
||||
# deafen, move, VAD, priority speaker, stream, soundboard...).
|
||||
VOICE_IMPLIED_BY_CONNECT = (
|
||||
(1 << 8) # PRIORITY_SPEAKER
|
||||
| (1 << 9) # STREAM
|
||||
| SPEAK
|
||||
| (1 << 22) # MUTE_MEMBERS
|
||||
| (1 << 23) # DEAFEN_MEMBERS
|
||||
| MOVE_MEMBERS
|
||||
| (1 << 25) # USE_VAD
|
||||
| (1 << 32) # REQUEST_TO_SPEAK
|
||||
| (1 << 39) # USE_EMBEDDED_ACTIVITIES
|
||||
| (1 << 42) # USE_SOUNDBOARD
|
||||
| (1 << 45) # USE_EXTERNAL_SOUNDS
|
||||
)
|
||||
|
||||
# Same bits as discord_leases.RADIO_BOT_PERMISSIONS, in the order the edge
|
||||
# lists them. Labels match node-26#38's ``_join_refusal`` strings exactly.
|
||||
REQUIRED_VOICE = ((VIEW_CHANNEL, "View Channel"), (CONNECT, "Connect"), (SPEAK, "Speak"))
|
||||
TRACKED = REQUIRED_VOICE + ((MOVE_MEMBERS, "Move Members"),)
|
||||
|
||||
GUILD_VOICE = 2 # Discord channel type for a normal voice channel
|
||||
|
||||
|
||||
def parse_permissions(value: Any) -> int:
|
||||
"""A Discord permission integer from its API string (or int / None).
|
||||
|
||||
Raises ValueError on anything else, so a malformed payload is "unknown"
|
||||
to the caller rather than silently read as "no permissions".
|
||||
"""
|
||||
if value is None or value == "":
|
||||
return 0
|
||||
if isinstance(value, bool):
|
||||
raise ValueError(f"not a permission integer: {value!r}")
|
||||
if isinstance(value, int):
|
||||
n = value
|
||||
elif isinstance(value, str) and value.strip().isdigit():
|
||||
n = int(value.strip())
|
||||
else:
|
||||
raise ValueError(f"not a permission integer: {value!r}")
|
||||
if n < 0:
|
||||
raise ValueError(f"not a permission integer: {value!r}")
|
||||
return n
|
||||
|
||||
|
||||
def _sid(value: Any) -> str:
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
def _role_label(role: Optional[Mapping[str, Any]], role_id: str) -> str:
|
||||
name = (role or {}).get("name") or f"role {role_id}"
|
||||
return name if name.startswith("@") else f"@{name}"
|
||||
|
||||
|
||||
def _channel_label(channel: Mapping[str, Any]) -> str:
|
||||
name = channel.get("name")
|
||||
return f"#{name}" if name else "the channel"
|
||||
|
||||
|
||||
def _overwrite_kind(ow: Mapping[str, Any]) -> Optional[str]:
|
||||
t = ow.get("type")
|
||||
if t in (0, "0", "role"):
|
||||
return "role"
|
||||
if t in (1, "1", "member"):
|
||||
return "member"
|
||||
return None
|
||||
|
||||
|
||||
def _timed_out(member: Mapping[str, Any], now: datetime) -> bool:
|
||||
raw = member.get("communication_disabled_until")
|
||||
if not raw:
|
||||
return False
|
||||
try:
|
||||
until = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return False
|
||||
if until.tzinfo is None:
|
||||
until = until.replace(tzinfo=timezone.utc)
|
||||
return until > now
|
||||
|
||||
|
||||
class _Tracer:
|
||||
"""Remembers, per tracked bit, the last step that removed it."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.reasons: dict[int, str] = {}
|
||||
|
||||
def step(self, before: int, after: int, reason: str) -> int:
|
||||
for bit, _ in TRACKED:
|
||||
if before & bit and not after & bit:
|
||||
self.reasons[bit] = reason
|
||||
elif after & bit:
|
||||
self.reasons.pop(bit, None)
|
||||
return after
|
||||
|
||||
def result(self, bits: int) -> dict[str, str]:
|
||||
return {label: self.reasons.get(bit, "not granted")
|
||||
for bit, label in TRACKED if not bits & bit}
|
||||
|
||||
|
||||
def effective_permissions(
|
||||
guild: Mapping[str, Any],
|
||||
member: Mapping[str, Any],
|
||||
channel: Mapping[str, Any],
|
||||
*,
|
||||
now: Optional[datetime] = None,
|
||||
) -> tuple[int, dict[str, str]]:
|
||||
"""The member's permission bits in ``channel``, and why tracked bits are missing.
|
||||
|
||||
``trace`` maps a label ("View Channel", "Connect", "Speak",
|
||||
"Move Members") to a sentence naming what removed it; only labels the
|
||||
member ends up WITHOUT appear. Raises ValueError on a malformed
|
||||
permission value.
|
||||
"""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
guild_id = _sid(guild.get("id"))
|
||||
user = member.get("user") or {}
|
||||
member_id = _sid(user.get("id") or member.get("id"))
|
||||
member_role_ids = {_sid(r) for r in (member.get("roles") or [])}
|
||||
roles = {_sid(r.get("id")): r for r in (guild.get("roles") or [])}
|
||||
chan = _channel_label(channel)
|
||||
tracer = _Tracer()
|
||||
|
||||
# 1. Owner.
|
||||
if member_id and member_id == _sid(guild.get("owner_id")):
|
||||
return ALL_PERMISSIONS, {}
|
||||
|
||||
# 2. Base from @everyone + the member's roles.
|
||||
everyone_role = roles.get(guild_id)
|
||||
base = parse_permissions(everyone_role.get("permissions")) if everyone_role else 0
|
||||
for rid in member_role_ids:
|
||||
role = roles.get(rid)
|
||||
if role is not None and rid != guild_id:
|
||||
base |= parse_permissions(role.get("permissions"))
|
||||
if base & ADMINISTRATOR:
|
||||
return ALL_PERMISSIONS, {}
|
||||
tracer.step(ALL_PERMISSIONS, base,
|
||||
"not granted by @everyone or any of the bot's roles (Server Settings → Roles)")
|
||||
perms = base
|
||||
|
||||
everyone_ow = None
|
||||
role_ows: list[Mapping[str, Any]] = []
|
||||
member_ow = None
|
||||
for ow in channel.get("permission_overwrites") or []:
|
||||
oid = _sid(ow.get("id"))
|
||||
kind = _overwrite_kind(ow)
|
||||
if oid == guild_id and kind != "member":
|
||||
everyone_ow = ow
|
||||
elif kind == "member" or (kind is None and oid == member_id):
|
||||
if oid == member_id:
|
||||
member_ow = ow
|
||||
elif oid in member_role_ids:
|
||||
role_ows.append(ow)
|
||||
|
||||
# 3. @everyone overwrite.
|
||||
if everyone_ow is not None:
|
||||
deny = parse_permissions(everyone_ow.get("deny"))
|
||||
allow = parse_permissions(everyone_ow.get("allow"))
|
||||
perms = tracer.step(perms, (perms & ~deny) | allow,
|
||||
f"denied by {chan}'s override for @everyone")
|
||||
|
||||
# 4. Role overwrites: all denies, then all allows.
|
||||
if role_ows:
|
||||
deny = allow = 0
|
||||
deniers: dict[int, list[str]] = {}
|
||||
for ow in sorted(role_ows, key=lambda o: _sid(o.get("id"))):
|
||||
d = parse_permissions(ow.get("deny"))
|
||||
deny |= d
|
||||
allow |= parse_permissions(ow.get("allow"))
|
||||
oid = _sid(ow.get("id"))
|
||||
for bit, _ in TRACKED:
|
||||
if d & bit:
|
||||
deniers.setdefault(bit, []).append(_role_label(roles.get(oid), oid))
|
||||
after = (perms & ~deny) | allow
|
||||
for bit, _ in TRACKED:
|
||||
if perms & bit and not after & bit:
|
||||
tracer.reasons[bit] = (
|
||||
f"denied by {chan}'s override for {', '.join(deniers.get(bit, []))}")
|
||||
elif after & bit:
|
||||
tracer.reasons.pop(bit, None)
|
||||
perms = after
|
||||
|
||||
# 5. The member's own overwrite.
|
||||
if member_ow is not None:
|
||||
deny = parse_permissions(member_ow.get("deny"))
|
||||
allow = parse_permissions(member_ow.get("allow"))
|
||||
perms = tracer.step(perms, (perms & ~deny) | allow,
|
||||
f"denied by {chan}'s override for the bot itself")
|
||||
|
||||
# 6. Implicit denials.
|
||||
if not perms & VIEW_CHANNEL:
|
||||
perms = tracer.step(perms, 0, f"the bot can't see {chan} without View Channel")
|
||||
elif channel.get("type") == GUILD_VOICE and not perms & CONNECT:
|
||||
perms = tracer.step(perms, perms & ~VOICE_IMPLIED_BY_CONNECT,
|
||||
f"Discord removes it in {chan} without Connect")
|
||||
|
||||
# 7. Timeout.
|
||||
if _timed_out(member, now):
|
||||
perms = tracer.step(perms, perms & TIMEOUT_MASK, "the bot is timed out in this server")
|
||||
|
||||
return perms, tracer.result(perms)
|
||||
|
||||
|
||||
def check_voice(
|
||||
bits: int,
|
||||
channel: Mapping[str, Any],
|
||||
trace: Optional[Mapping[str, str]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Can a bot with ``bits`` in ``channel`` stream into it?
|
||||
|
||||
Only a normal voice channel (type 2) counts; anything else is
|
||||
``ok=False, is_voice=False`` with nothing ``missing``. ``user_limit`` is
|
||||
advisory: REST can't see who is connected, so the edge's ``channel_full``
|
||||
stays the enforcement. ``limit_bypass`` is Move Members, which Discord
|
||||
lets past a full channel. ``reasons`` carries the trace sentence for each
|
||||
missing label when ``trace`` is given.
|
||||
"""
|
||||
is_voice = channel.get("type") == GUILD_VOICE
|
||||
try:
|
||||
user_limit = int(channel.get("user_limit") or 0)
|
||||
except (TypeError, ValueError):
|
||||
user_limit = 0
|
||||
missing = [label for bit, label in REQUIRED_VOICE if not bits & bit] if is_voice else []
|
||||
reasons = {label: trace[label] for label in missing if trace and label in trace}
|
||||
return {
|
||||
"ok": is_voice and not missing,
|
||||
"is_voice": is_voice,
|
||||
"missing": missing,
|
||||
"reasons": reasons,
|
||||
"user_limit": user_limit,
|
||||
"limit_bypass": bool(bits & MOVE_MEMBERS),
|
||||
}
|
||||
Reference in New Issue
Block a user