Discord: channel permission math for radio bots (#225 S1) #230

Merged
logan merged 2 commits from feat/225-s1-discord-perms into main 2026-10-05 02:03:28 -04:00
Owner

Slice S1 of #225 (permission math). New files only; no caller yet (S3 wires it into pick_token).

  • app/internal/discord_perms.py
    • effective_permissions(guild, member, channel, *, now=None) -> (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 (discord.py's rule, so labels match the edge) → timeout keeps only View + Read History.
    • trace maps each missing radio permission to what removed it, 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 _join_refusal exactly. Move Members = limit bypass; user limit is advisory.
    • Permission strings parsed with int (values above 2**53 tested). Malformed values raise ValueError, so S3 can treat them as unknown (fail-open) instead of a silent deny.
  • tests/test_discord_perms.py: 37 tests, including the required-bits check against discord_leases.RADIO_BOT_PERMISSIONS.

Tests: pytest tests/test_discord_perms.py 37 passed; full c2-core suite 723 passed. flake8 (max 120) clean on both files.

Not merged; the main session merges after review.

🤖 Generated with Claude Code

Slice **S1** of #225 (permission math). New files only; no caller yet (S3 wires it into `pick_token`). - `app/internal/discord_perms.py` - `effective_permissions(guild, member, channel, *, now=None) -> (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 (discord.py's rule, so labels match the edge) → timeout keeps only View + Read History. - `trace` maps each missing radio permission to what removed it, 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 `_join_refusal` exactly. Move Members = limit bypass; user limit is advisory. - Permission strings parsed with `int` (values above 2**53 tested). Malformed values raise `ValueError`, so S3 can treat them as unknown (fail-open) instead of a silent deny. - `tests/test_discord_perms.py`: 37 tests, including the required-bits check against `discord_leases.RADIO_BOT_PERMISSIONS`. Tests: `pytest tests/test_discord_perms.py` 37 passed; full c2-core suite 723 passed. flake8 (max 120) clean on both files. Not merged; the main session merges after review. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
logan added 1 commit 2026-10-05 01:54:12 -04:00
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>
logan added 1 commit 2026-10-05 01:57:32 -04:00
A guild without its @everyone role (or that role's permissions) was read
as base 0, a definite deny that would block a start. It now raises
ValueError, which S3 treats as unknown and lets the start go ahead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

S1 review: ship (not merged).

  • Differential fuzz: 4000 random guild/role/overwrite/timeout/owner/admin cases vs discord.py 2.7.1 VoiceChannel.permissions_for. 0 mismatches on View/Connect/Speak/Move Members.
  • Labels match node-26 _join_refusal exactly. Required bits equal RADIO_BOT_PERMISSIONS.
  • Fixed in 9e850fe: a guild payload with no @everyone role (or no permissions on it) used to become base 0, which is a definite deny. It now raises ValueError, so S3 treats it as unknown and fails open.
  • Tests: 41 passed (S1), 727 passed (full c2-core). flake8 is clean.
  • Note for S3: check_voice returns ok=False with missing=[] for channels that are not voice. Map that to bad_channel, not to missing_permissions.

🤖 Generated with Claude Code

S1 review: ship (not merged). - Differential fuzz: 4000 random guild/role/overwrite/timeout/owner/admin cases vs discord.py 2.7.1 `VoiceChannel.permissions_for`. 0 mismatches on View/Connect/Speak/Move Members. - Labels match node-26 `_join_refusal` exactly. Required bits equal `RADIO_BOT_PERMISSIONS`. - Fixed in 9e850fe: a guild payload with no @everyone role (or no permissions on it) used to become base 0, which is a definite deny. It now raises ValueError, so S3 treats it as unknown and fails open. - Tests: 41 passed (S1), 727 passed (full c2-core). flake8 is clean. - Note for S3: `check_voice` returns ok=False with missing=[] for channels that are not voice. Map that to bad_channel, not to missing_permissions. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
logan merged commit d6ea65ac2b into main 2026-10-05 02:03:28 -04:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: logan/server-26#230