Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions gateway/run_voice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fold this voice repair into #128788 and finish its native siblings. Rechecking the current speaker instead of inheriting the joiner's grant is the correct bounded behavior, and the new positive case passes through real gateway authorization. But the composed Discord set still fails a legitimate role-only native slash member: _build_slash_event and _dispatch_thread_session omit the analogous verified verdict. Please make one admission-to-event repair with positive gateway assertions for speech, slash and thread starter, and retain role-removal/missing-member controls. This comment is about the unresolved class boundary; I found no newly reproduced defect in this voice verdict itself.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Native siblings fixed on this branch. f5f538ef4b and e4165fc2ed are #125620, salvaged with its author kept. 357e77d092 makes _build_slash_event and _dispatch_thread_session stamp the grant from _slash_role_grant(interaction), the slash gate re-evaluated for this actor. One parametrized test runs both builders through the real gateway _is_user_authorized: a role member is admitted, and an unrelated-role actor is refused. The voice role-removal and missing-member controls are unchanged. It stays a separate PR from #128788 for the reason in the top-level reply.

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
Expand Down
11 changes: 11 additions & 0 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
32 changes: 32 additions & 0 deletions tests/gateway/test_discord_slash_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
55 changes: 55 additions & 0 deletions tests/gateway/test_discord_voice_role_grant.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion website/docs/user-guide/features/voice-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ Bot 在播放 TTS 回复时会自动暂停音频监听,防止听到并重复

### 访问控制

只有 `DISCORD_ALLOWED_USERS` 中列出的用户才能通过语音进行交互。其他用户的音频会被静默忽略。
只有 `DISCORD_ALLOWED_USERS` 或 `DISCORD_ALLOWED_ROLES` 允许的用户才能通过语音进行交互;每次说话时都会为说话者检查身份组。其他用户的音频会被静默忽略。

```bash
# ~/.hermes/.env
Expand Down