feat(discord): add DISCORD_ALLOWED_ROLES env var for role-based access control
Adds a new DISCORD_ALLOWED_ROLES environment variable that allows filtering bot interactions by Discord role ID. Uses OR semantics with the existing DISCORD_ALLOWED_USERS - if a user matches either allowlist, they're permitted. Changes: - Parse DISCORD_ALLOWED_ROLES comma-separated role IDs on connect - Enable members intent when roles are configured (needed for role lookup) - Update _is_allowed_user() to accept optional author param for direct role check - Fallback to scanning mutual guilds when author object lacks roles (DMs, voice) - Fully backwards compatible: no behavior change when env var is unset
This commit is contained in:
@@ -495,6 +495,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||||||
self._client: Optional[commands.Bot] = None
|
self._client: Optional[commands.Bot] = None
|
||||||
self._ready_event = asyncio.Event()
|
self._ready_event = asyncio.Event()
|
||||||
self._allowed_user_ids: set = set() # For button approval authorization
|
self._allowed_user_ids: set = set() # For button approval authorization
|
||||||
|
self._allowed_role_ids: set = set() # For DISCORD_ALLOWED_ROLES filtering
|
||||||
# Voice channel state (per-guild)
|
# Voice channel state (per-guild)
|
||||||
self._voice_clients: Dict[int, Any] = {} # guild_id -> VoiceClient
|
self._voice_clients: Dict[int, Any] = {} # guild_id -> VoiceClient
|
||||||
# Text batching: merge rapid successive messages (Telegram-style)
|
# Text batching: merge rapid successive messages (Telegram-style)
|
||||||
@@ -573,6 +574,15 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||||||
if uid.strip()
|
if uid.strip()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Parse DISCORD_ALLOWED_ROLES — comma-separated role IDs.
|
||||||
|
# Users with ANY of these roles can interact with the bot.
|
||||||
|
roles_env = os.getenv("DISCORD_ALLOWED_ROLES", "")
|
||||||
|
if roles_env:
|
||||||
|
self._allowed_role_ids = {
|
||||||
|
int(rid.strip()) for rid in roles_env.split(",")
|
||||||
|
if rid.strip().isdigit()
|
||||||
|
}
|
||||||
|
|
||||||
# Set up intents.
|
# Set up intents.
|
||||||
# Message Content is required for normal text replies.
|
# Message Content is required for normal text replies.
|
||||||
# Server Members is only needed when the allowlist contains usernames
|
# Server Members is only needed when the allowlist contains usernames
|
||||||
@@ -584,7 +594,10 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||||||
intents.message_content = True
|
intents.message_content = True
|
||||||
intents.dm_messages = True
|
intents.dm_messages = True
|
||||||
intents.guild_messages = True
|
intents.guild_messages = True
|
||||||
intents.members = any(not entry.isdigit() for entry in self._allowed_user_ids)
|
intents.members = (
|
||||||
|
any(not entry.isdigit() for entry in self._allowed_user_ids)
|
||||||
|
or bool(self._allowed_role_ids) # Need members intent for role lookup
|
||||||
|
)
|
||||||
intents.voice_states = True
|
intents.voice_states = True
|
||||||
|
|
||||||
# Resolve proxy (DISCORD_PROXY > generic env vars > macOS system proxy)
|
# Resolve proxy (DISCORD_PROXY > generic env vars > macOS system proxy)
|
||||||
@@ -653,8 +666,8 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||||||
# "all" falls through; bot is permitted — skip the
|
# "all" falls through; bot is permitted — skip the
|
||||||
# human-user allowlist below (bots aren't in it).
|
# human-user allowlist below (bots aren't in it).
|
||||||
else:
|
else:
|
||||||
# Non-bot: enforce the configured user allowlist.
|
# Non-bot: enforce the configured user/role allowlists.
|
||||||
if not self._is_allowed_user(str(message.author.id)):
|
if not self._is_allowed_user(str(message.author.id), message.author):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Multi-agent filtering: if the message mentions specific bots
|
# Multi-agent filtering: if the message mentions specific bots
|
||||||
@@ -1365,11 +1378,43 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _is_allowed_user(self, user_id: str) -> bool:
|
def _is_allowed_user(self, user_id: str, author=None) -> bool:
|
||||||
"""Check if user is in DISCORD_ALLOWED_USERS."""
|
"""Check if user is allowed via DISCORD_ALLOWED_USERS or DISCORD_ALLOWED_ROLES.
|
||||||
if not self._allowed_user_ids:
|
|
||||||
|
Uses OR semantics: if the user matches EITHER allowlist, they're allowed.
|
||||||
|
If both allowlists are empty, everyone is allowed (backwards compatible).
|
||||||
|
When author is a Member, checks .roles directly; otherwise falls back
|
||||||
|
to scanning the bot's mutual guilds for a Member record.
|
||||||
|
"""
|
||||||
|
has_users = bool(self._allowed_user_ids)
|
||||||
|
has_roles = bool(self._allowed_role_ids)
|
||||||
|
if not has_users and not has_roles:
|
||||||
return True
|
return True
|
||||||
return user_id in self._allowed_user_ids
|
# Check user ID allowlist
|
||||||
|
if has_users and user_id in self._allowed_user_ids:
|
||||||
|
return True
|
||||||
|
# Check role allowlist
|
||||||
|
if has_roles:
|
||||||
|
# Try direct role check from Member object
|
||||||
|
direct_roles = getattr(author, "roles", None) if author is not None else None
|
||||||
|
if direct_roles:
|
||||||
|
if any(getattr(r, "id", None) in self._allowed_role_ids for r in direct_roles):
|
||||||
|
return True
|
||||||
|
# Fallback: scan mutual guilds for member's roles
|
||||||
|
if self._client is not None:
|
||||||
|
try:
|
||||||
|
uid_int = int(user_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
uid_int = None
|
||||||
|
if uid_int is not None:
|
||||||
|
for guild in self._client.guilds:
|
||||||
|
m = guild.get_member(uid_int)
|
||||||
|
if m is None:
|
||||||
|
continue
|
||||||
|
m_roles = getattr(m, "roles", None) or []
|
||||||
|
if any(getattr(r, "id", None) in self._allowed_role_ids for r in m_roles):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
async def send_image_file(
|
async def send_image_file(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -2655,6 +2655,18 @@ class GatewayRunner:
|
|||||||
if allow_bots in ("mentions", "all"):
|
if allow_bots in ("mentions", "all"):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# Discord role-based access (DISCORD_ALLOWED_ROLES): the adapter's
|
||||||
|
# on_message pre-filter already verified role membership — if the
|
||||||
|
# message reached here, the user passed that check. Authorize
|
||||||
|
# directly to avoid the "no allowlists configured" branch below
|
||||||
|
# rejecting role-only setups where DISCORD_ALLOWED_USERS is empty
|
||||||
|
# (issue #7871).
|
||||||
|
if (
|
||||||
|
source.platform == Platform.DISCORD
|
||||||
|
and os.getenv("DISCORD_ALLOWED_ROLES", "").strip()
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
|
||||||
# Check pairing store (always checked, regardless of allowlists)
|
# Check pairing store (always checked, regardless of allowlists)
|
||||||
platform_name = source.platform.value if source.platform else ""
|
platform_name = source.platform.value if source.platform else ""
|
||||||
if self.pairing_store.is_approved(platform_name, user_id):
|
if self.pairing_store.is_approved(platform_name, user_id):
|
||||||
|
|||||||
Reference in New Issue
Block a user