diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 8a83f8805ed..d9f03b6fbba 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -3227,6 +3227,18 @@ class DiscordAdapter(BasePlatformAdapter): return True return bool(channel_ids & allowed) + def _is_pairing_approved_user(self, user_id: str) -> bool: + """True when the Discord user has an explicit Hermes pairing grant.""" + user_id = str(user_id or "").strip() + if not user_id: + return False + try: + from gateway.pairing import PairingStore + + return bool(PairingStore().is_approved("discord", user_id)) + except Exception: + return False + def _is_allowed_user( self, user_id: str, @@ -3267,6 +3279,14 @@ class DiscordAdapter(BasePlatformAdapter): allowed_roles = getattr(self, "_allowed_role_ids", set()) has_users = bool(allowed_users) has_roles = bool(allowed_roles) + + # Pairing is a first-class auth grant in the gateway auth union and in + # Discord component buttons. Honor it here too so normal guild/DM text + # messages do not get dropped at the adapter before the pairing-aware + # gateway layer can see them. + if self._is_pairing_approved_user(user_id): + return True + if not has_users and not has_roles: if os.getenv("DISCORD_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}: return True diff --git a/tests/gateway/test_discord_slash_auth.py b/tests/gateway/test_discord_slash_auth.py index f9bb7f40ca8..a8d7e21e0a8 100644 --- a/tests/gateway/test_discord_slash_auth.py +++ b/tests/gateway/test_discord_slash_auth.py @@ -178,6 +178,18 @@ def _make_interaction( ) +def _stub_pairing_store(monkeypatch, approved_ids): + approved = {str(uid) for uid in approved_ids} + + class _FakePairingStore: + def is_approved(self, platform, user_id): + return platform == "discord" and str(user_id) in approved + + import gateway.pairing as pairing + + monkeypatch.setattr(pairing, "PairingStore", _FakePairingStore) + + # --------------------------------------------------------------------------- # Backwards-compat: empty allowlist → everything passes (matches on_message) # --------------------------------------------------------------------------- @@ -235,6 +247,27 @@ async def test_disallowed_user_rejected_with_ephemeral(adapter, caplog): assert any("DISCORD_ALLOWED_USERS" in r.message for r in caplog.records) +def test_pairing_approved_user_passes_message_gate_without_allowlist(adapter, monkeypatch): + """Pairing grants must be honored before on_message drops guild mentions.""" + _stub_pairing_store(monkeypatch, {"100200300"}) + assert adapter._is_allowed_user( + "100200300", + author=SimpleNamespace(id=100200300), + guild=SimpleNamespace(id=42, get_member=lambda _uid: None), + is_dm=False, + channel_ids={"12345"}, + ) is True + + +@pytest.mark.asyncio +async def test_pairing_approved_user_passes_slash_gate_without_allowlist(adapter, monkeypatch): + """Slash and normal text auth share the pairing-aware user gate.""" + _stub_pairing_store(monkeypatch, {"100200300"}) + interaction = _make_interaction("100200300") + assert await adapter._check_slash_authorization(interaction, "/help") is True + interaction.response.send_message.assert_not_awaited() + + # --------------------------------------------------------------------------- # Role allowlist (DISCORD_ALLOWED_ROLES) parity # ---------------------------------------------------------------------------