diff --git a/gateway/config_loader.py b/gateway/config_loader.py index 9ed0042842..334d7024c5 100644 --- a/gateway/config_loader.py +++ b/gateway/config_loader.py @@ -13,7 +13,7 @@ import os from pathlib import Path from typing import Any -from gateway.config import Platform, PlatformConfig, _coerce_dict, _dict_slot, _normalize_choice +from gateway.config import UNAUTHORIZED_DM_BEHAVIORS, Platform, PlatformConfig, _coerce_dict, _dict_slot, _normalize_choice # Logger name parity with the origin module: records stay under "gateway.config". logger = logging.getLogger("gateway.config") @@ -59,7 +59,7 @@ def _quick_commands_ok(value: Any) -> bool: def _dm_behavior_choice(value: Any, default: str = "pair") -> str: - return _normalize_choice(value, {"pair", "ignore"}, default) + return _normalize_choice(value, UNAUTHORIZED_DM_BEHAVIORS, default) def _presence(*keys: str) -> tuple: @@ -83,6 +83,7 @@ _TOPLEVEL_BRIDGE: tuple = ( "filter_silence_narration", ), ("unauthorized_dm_behavior", "unauthorized_dm_behavior", "presence", None, _dm_behavior_choice), + *_presence("unauthorized_dm_decline_message"), ) diff --git a/gateway/run_inbound.py b/gateway/run_inbound.py index 7cb87ac6b9..ac7d4fe3ee 100644 --- a/gateway/run_inbound.py +++ b/gateway/run_inbound.py @@ -111,7 +111,7 @@ class GatewayInboundMixin: async def _hm_send_unauthorized_decline(self, source: SessionSource) -> None: """``decline`` behavior: one short refusal per sender per DECLINE_DEDUPE_SECONDS, then silence - (port of qwibitai/nanoclaw#3260, #88028). The stamp is written BEFORE the send so a delivery + (#88028). The stamp is written BEFORE the send so a delivery hiccup cannot become a decline storm; without a store there is no dedupe state → stay silent.""" from gateway.config import DEFAULT_UNAUTHORIZED_DM_DECLINE_MESSAGE platform_name = source.platform.value if source.platform else "unknown" diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 0c872421f5..fd5bd8e25b 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -5059,19 +5059,22 @@ def _prompt_csv(prompt_text: str, default: str) -> str: # (default index, *choices) for the no-allowlist access prompt, keyed by is_email. _UNAUTHORIZED_ACCESS_CHOICES = { - True: (2, + True: (3, "Enable open access (any email sender can message the bot)", "Use DM pairing (unknown email senders receive a pairing code)", + "Politely decline unknown senders (one-time message, then silence)", "Keep unknown senders silent"), False: (1, "Enable open access (anyone can message the bot)", "Use DM pairing (unknown users request access, you approve with 'hermes pairing approve')", + "Politely decline unknown senders (one-time message, then silence)", "Skip for now (bot will deny all users until configured)"), } -def _prompt_unauthorized_access(*, is_email: bool) -> None: - """No allowlist was given — ask open access vs DM pairing vs skip/silent, and persist.""" +def _prompt_unauthorized_access(platform_key: str) -> None: + """No allowlist was given — ask open access vs DM pairing vs decline vs skip/silent, and persist.""" + is_email = platform_key == "email" print() default_idx, *access_choices = _UNAUTHORIZED_ACCESS_CHOICES[is_email] access_idx = prompt_choice(" How should unauthorized users be handled?", access_choices, default_idx) @@ -5083,6 +5086,9 @@ def _prompt_unauthorized_access(*, is_email: bool) -> None: _set_platform_unauthorized_dm_behavior("email", "pair") print_success(" DM pairing mode — users will receive a code to request access.") print_info(" Approve with: hermes pairing approve ") + elif access_idx == 2: + _set_platform_unauthorized_dm_behavior(platform_key, "decline") + print_success(" Unknown senders get one polite decline, then silence (unauthorized_dm_behavior: decline).") elif is_email: print_success(" Unknown email senders will be ignored.") else: @@ -5154,7 +5160,7 @@ def _prompt_allowlist_var(var: dict, platform_key: str, auto_owner_user_id) -> s ) value = prompt(f" {var['prompt']}", password=False) if not value: - _prompt_unauthorized_access(is_email=platform_key == "email") + _prompt_unauthorized_access(platform_key) return None cleaned = value.replace(" ", "") if "DISCORD" in var["name"]: diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index d81cd696f9..6de3c164e6 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -842,8 +842,9 @@ class TelegramAdapter(BasePlatformAdapter): return any(_scoped_gate_env(key).strip() for key in keys) def _should_pass_unauthorized_dm_for_pairing(self, source) -> bool: - """True when an unauthorized DM must still reach gateway pairing (``unauthorized_dm_behavior`` - resolves to ``pair``, incl. an allowlist plus an explicit platform override).""" + """True when an unauthorized DM must still reach the gateway for an outbound reply + (``unauthorized_dm_behavior`` resolves to anything but ``ignore`` — a pairing code or a + one-time decline — incl. an allowlist plus an explicit platform override).""" if source.chat_type != "dm": return False # Bound-handler ``__self__`` is None under multiplex; ``gateway_runner`` survives that wrapping. @@ -852,11 +853,11 @@ class TelegramAdapter(BasePlatformAdapter): if callable(behavior_fn): try: profile = getattr(source, "profile", None) or getattr(self, "_owner_profile", None) - return behavior_fn(Platform.TELEGRAM, profile=profile) == "pair" + return behavior_fn(Platform.TELEGRAM, profile=profile) != "ignore" except Exception: logger.debug("[Telegram] Failed to resolve unauthorized DM behavior; falling back to adapter-local override", exc_info=True) extra = getattr(getattr(self, "config", None), "extra", None) or {} - return str(extra.get("unauthorized_dm_behavior", "")).strip().lower() == "pair" + return str(extra.get("unauthorized_dm_behavior", "")).strip().lower() in ("pair", "decline") def _is_user_authorized_from_message(self, message: Message) -> bool: """Intake auth prefilter, run BEFORE batching/event construction/group observation. diff --git a/tests/gateway/test_unauthorized_dm_behavior.py b/tests/gateway/test_unauthorized_dm_behavior.py index 1f4b7ee6a1..1e7d47f567 100644 --- a/tests/gateway/test_unauthorized_dm_behavior.py +++ b/tests/gateway/test_unauthorized_dm_behavior.py @@ -417,8 +417,7 @@ def test_qqbot_with_allowlist_ignores_unauthorized_dm(monkeypatch): # --------------------------------------------------------------------------- -# "decline" behavior: one-time polite decline instead of a pairing code -# (ported from qwibitai/nanoclaw#3260, #88028) +# "decline" behavior: one-time polite decline instead of a pairing code (#88028) # --------------------------------------------------------------------------- @pytest.mark.asyncio @@ -451,18 +450,32 @@ async def test_unauthorized_dm_decline_sends_once_then_stays_silent(monkeypatch) def test_decline_config_and_stamp_roundtrip(monkeypatch, tmp_path): - """Config accepts 'decline' (case-insensitive) and round-trips the custom text; a real - PairingStore persists the stamp, scopes it per sender, and expires it after the window.""" + """The real startup path (config.yaml -> load_gateway_config) keeps 'decline' (case-insensitive) + at top level and as a platform override, and carries the custom text; a real PairingStore + persists the stamp, scopes it per sender, and expires it after the window.""" from unittest.mock import patch as _patch import gateway.pairing as pairing_mod + from gateway.config import load_gateway_config - config = GatewayConfig.from_dict( - {"unauthorized_dm_behavior": "DECLINE", "unauthorized_dm_decline_message": " custom text "} + _clear_auth_env(monkeypatch) + (tmp_path / "config.yaml").write_text( + "unauthorized_dm_behavior: DECLINE\n" + "unauthorized_dm_decline_message: ' custom text '\n" + "platforms:\n" + " telegram:\n" + " unauthorized_dm_behavior: pair\n" + " whatsapp:\n" + " unauthorized_dm_behavior: decline\n", + encoding="utf-8", ) + with _patch("gateway.config.get_hermes_home", return_value=tmp_path): + config = load_gateway_config() assert config.unauthorized_dm_behavior == "decline" - assert config.get_unauthorized_dm_behavior(Platform.TELEGRAM) == "decline" - assert config.to_dict()["unauthorized_dm_behavior"] == "decline" + assert config.unauthorized_dm_decline_message == "custom text" + assert config.get_unauthorized_dm_behavior(Platform.TELEGRAM) == "pair" + assert config.get_unauthorized_dm_behavior(Platform.WHATSAPP) == "decline" + assert config.get_unauthorized_dm_behavior(Platform.DISCORD) == "decline" assert GatewayConfig.from_dict(config.to_dict()).unauthorized_dm_decline_message == "custom text" with _patch("gateway.pairing.PAIRING_DIR", tmp_path): diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 5ad3c1bf8d..62fe7d0bbf 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -2429,6 +2429,8 @@ whatsapp: unauthorized_dm_decline_message: "Sorry, this assistant is private." ``` + `hermes gateway setup` offers this as "Politely decline unknown senders" when you leave the allowlist empty; it writes `platforms..unauthorized_dm_behavior: decline`. + - Email defaults to `ignore` unless `platforms.email.unauthorized_dm_behavior: pair` is set, because inboxes can contain unrelated unread mail. - Platform sections override the global default, so you can keep pairing enabled broadly while making one platform quieter.