diff --git a/gateway/run_voice.py b/gateway/run_voice.py index 36c0440048674..8c72ff58a313b 100644 --- a/gateway/run_voice.py +++ b/gateway/run_voice.py @@ -267,6 +267,14 @@ async def _handle_voice_channel_input( # before TELEGRAM_ALLOWED_USERS (or equivalent) was configured, or before the owner was removed from # it, must not silently receive a full agent response on gateway restart just because it has a # resume-pending marker (issue #23778). + # The /voice join copy never carries the per-event role grant (to_dict drops it), so a + # role-only speaker was always refused here. Recompute it for THIS speaker against the + # guild's current member, with the flag's message-path meaning, never the joiner's. + if isinstance(roles := getattr(adapter, "_allowed_role_ids", None), (set, frozenset)) and roles: + client = getattr(adapter, "_client", None) + guild = client.get_guild(guild_id) if client else None + source.role_authorized = guild is not None and adapter._is_allowed_user( + str(user_id), guild=guild, is_dm=False) is True if not self._is_user_authorized_for_source(source): logger.debug("Unauthorized voice input from user %d, ignoring", user_id) return diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index f77f812d09385..75f669a6f05bc 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -4100,6 +4100,14 @@ def _evaluate_slash_authorization( return (False, "user not in DISCORD_ALLOWED_USERS / DISCORD_ALLOWED_ROLES") return (True, None) + def _slash_role_grant(self, interaction: "discord.Interaction") -> bool: + """``role_authorized`` for an event built from ``interaction``, with the message path's + meaning (_discord_message_admission): a role allowlist exists and THIS actor passes the gate. + Re-evaluated for the interaction instead of trusted from call order, so a builder can never + stamp the grant on an actor the slash gate would refuse.""" + return bool(getattr(self, "_allowed_role_ids", set())) and self._evaluate_slash_authorization( + interaction)[0] + async def _check_slash_authorization( self, interaction: "discord.Interaction", command_text: str, ) -> bool: @@ -4721,11 +4729,13 @@ def _build_slash_event(self, interaction: discord.Interaction, text: str) -> Mes # them — without them a guild- or channel-routed profile never matches a native slash command # (#69178). parent_id = (self._get_parent_channel_id(interaction.channel) if is_thread else None) or "" + # Without the role grant the gateway refuses a role-only member the slash gate admitted. source = self.build_source( chat_id=str(interaction.channel_id), chat_name=chat_name, chat_type=chat_type, user_id=str(interaction.user.id), user_name=interaction.user.display_name, thread_id=thread_id, chat_topic=chat_topic, guild_id=self._interaction_guild_id(interaction), parent_chat_id=parent_id or None, + role_authorized=self._slash_role_grant(interaction), ) msg_type = MessageType.COMMAND if text.startswith("/") else MessageType.TEXT channel_id = str(interaction.channel_id) @@ -4786,6 +4796,7 @@ async def _dispatch_thread_session( user_id=str(interaction.user.id), user_name=interaction.user.display_name, thread_id=thread_id, chat_topic=chat_topic, guild_id=self._interaction_guild_id(interaction), parent_chat_id=_parent_id or None, + role_authorized=self._slash_role_grant(interaction), ) _skills = self._resolve_channel_skills(thread_id, _parent_id or None) _channel_prompt = self._resolve_channel_prompt(thread_id, _parent_id or None) diff --git a/tests/gateway/test_discord_slash_auth.py b/tests/gateway/test_discord_slash_auth.py index afcd12dc74d39..d41693a4bd7f6 100644 --- a/tests/gateway/test_discord_slash_auth.py +++ b/tests/gateway/test_discord_slash_auth.py @@ -243,6 +243,38 @@ async def test_role_member_passes(adapter): assert await adapter._check_slash_authorization(interaction, "/help") is True +def _gateway_authorizes(source): + """The real gateway authz verdict for a runner with no allowlist and no pairing store.""" + from gateway.run import GatewayRunner + + return object.__new__(GatewayRunner)._is_user_authorized(source) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("builder", ["slash", "thread_starter"]) +@pytest.mark.parametrize("actor_roles,gateway_admits", [ + ([1234], True), # role-only member the slash gate admits + ([7], False), # unrelated role: the builder must not mint a grant for this actor +]) +async def test_native_event_carries_this_actors_role_grant(adapter, builder, actor_roles, gateway_admits): + """Regression for #118958: a role-only member passes the slash gate, and the event a native + builder dispatches must carry that verdict, or the gateway answers with a pairing code.""" + adapter._allowed_role_ids = {1234} + interaction = _make_interaction("999999999") + interaction.user.roles = [SimpleNamespace(id=r) for r in actor_roles] + interaction.user.display_name = "member" + interaction.guild.name = "guild" + dispatched = [] + adapter.handle_message = AsyncMock(side_effect=dispatched.append) + + if builder == "slash": + dispatched.append(adapter._build_slash_event(interaction, "/reset")) + else: + await adapter._dispatch_thread_session(interaction, "555", "topic", "hello") + + assert [_gateway_authorizes(event.source) for event in dispatched] == [gateway_admits] + + # --------------------------------------------------------------------------- # Channel allowlist (DISCORD_ALLOWED_CHANNELS) parity — the gate prajer used # --------------------------------------------------------------------------- diff --git a/tests/gateway/test_discord_voice_role_grant.py b/tests/gateway/test_discord_voice_role_grant.py new file mode 100644 index 0000000000000..67320d08f0cb3 --- /dev/null +++ b/tests/gateway/test_discord_voice_role_grant.py @@ -0,0 +1,55 @@ +"""A spoken turn from a member admitted by ``DISCORD_ALLOWED_ROLES`` reaches the agent. + +The listen loop admits a speaker through the adapter's role check, but the voice source is rebuilt +from the ``/voice join`` copy, which never carries the per-event role grant, so the gateway's own +check refused every role-only speaker. The grant is recomputed for the current speaker against the +guild's current member, never inherited from whoever ran ``/voice join``. +""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +import gateway.run as gateway_run +from gateway.config import Platform, PlatformConfig +from gateway.session import SessionSource +from plugins.platforms.discord.adapter import DiscordAdapter + +_ROLE, _GUILD, _TEXT, _JOINER = 1234, 1, 700, 42 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("speaker_roles,dispatched", [ + ([_ROLE], 1), # role member: admitted on both gates + ([], 0), # the joiner holds the role, this speaker does not + (None, 0), # speaker no longer in the guild +]) +async def test_role_member_speech_passes_the_gateway_gate(monkeypatch, speaker_roles, dispatched): + for var in ("DISCORD_ALLOWED_USERS", "DISCORD_ALLOW_ALL_USERS", "GATEWAY_ALLOWED_USERS", + "GATEWAY_ALLOW_ALL_USERS"): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("DISCORD_ALLOWED_ROLES", str(_ROLE)) + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="fake")) + adapter._allowed_user_ids, adapter._allowed_role_ids = set(), {_ROLE} + speaker = 43 + members = {_JOINER: SimpleNamespace(id=_JOINER, roles=[SimpleNamespace(id=_ROLE)])} + if speaker_roles is not None: + members[speaker] = SimpleNamespace(id=speaker, roles=[SimpleNamespace(id=r) for r in speaker_roles]) + guild = SimpleNamespace(id=_GUILD, name="Hermes Server", get_member=members.get) + adapter._client = SimpleNamespace(get_guild=lambda _id: guild, get_channel=lambda _id: None) + adapter._voice_text_channels = {_GUILD: _TEXT} + joined = SessionSource(platform=Platform.DISCORD, chat_id=str(_TEXT), chat_type="group", + user_id=str(_JOINER), user_name="joiner", guild_id=str(_GUILD), + role_authorized=True) + adapter._voice_sources = {_GUILD: joined.to_dict()} + adapter.handle_message = AsyncMock() + runner = object.__new__(gateway_run.GatewayRunner) + runner.adapters = {Platform.DISCORD: adapter} + runner._voice_mode, runner._session_db, runner.session_store = {}, None, MagicMock() + + await runner._handle_voice_channel_input(_GUILD, speaker, "what broke?", adapter=adapter) + + assert adapter.handle_message.await_count == dispatched + for call in adapter.handle_message.await_args_list: + assert runner._is_user_authorized(call.args[0].source) diff --git a/website/docs/user-guide/features/voice-mode.md b/website/docs/user-guide/features/voice-mode.md index f119d16f938f0..a153a51d94c64 100644 --- a/website/docs/user-guide/features/voice-mode.md +++ b/website/docs/user-guide/features/voice-mode.md @@ -438,7 +438,7 @@ The bot automatically pauses its audio listener while playing TTS replies, preve ### Access Control -Only users listed in `DISCORD_ALLOWED_USERS` can interact via voice. Other users' audio is silently ignored. +Only users allowed by `DISCORD_ALLOWED_USERS` or `DISCORD_ALLOWED_ROLES` can interact via voice; a role is checked for the speaker each time they talk. Other users' audio is silently ignored. ```bash # ~/.hermes/.env diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/voice-mode.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/voice-mode.md index b0e837837b37d..e74bad9015cd2 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/voice-mode.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/voice-mode.md @@ -360,7 +360,7 @@ Bot 在播放 TTS 回复时会自动暂停音频监听,防止听到并重复 ### 访问控制 -只有 `DISCORD_ALLOWED_USERS` 中列出的用户才能通过语音进行交互。其他用户的音频会被静默忽略。 +只有 `DISCORD_ALLOWED_USERS` 或 `DISCORD_ALLOWED_ROLES` 允许的用户才能通过语音进行交互;每次说话时都会为说话者检查身份组。其他用户的音频会被静默忽略。 ```bash # ~/.hermes/.env