diff --git a/gateway/authz_mixin.py b/gateway/authz_mixin.py index 903554e3ff..6ea612b7bd 100644 --- a/gateway/authz_mixin.py +++ b/gateway/authz_mixin.py @@ -625,7 +625,7 @@ class GatewayAuthorizationMixin: return "*" in allowed_ids or _principal_matches_allowlist(source, user_id, allowed_ids) def _get_unauthorized_dm_behavior(self, platform: Optional[Platform], *, profile: Optional[str] = None) -> str: - """How unauthorized DMs are handled ("pair" / "ignore") for a platform. + """How unauthorized DMs are handled ("pair" / "ignore" / "decline") for a platform. Order: explicit per-platform config; Email → "ignore" (inboxes hold arbitrary mail); explicit non-default global; adapter dm_policy (pairing → "pair", allowlist/disabled → "ignore"); any diff --git a/gateway/config.py b/gateway/config.py index 82de453ef9..2437f539b9 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -133,6 +133,14 @@ def _coerce_dict(value: Any) -> Dict[str, Any]: return value if isinstance(value, dict) else {} +# "pair" DMs a pairing code, "ignore" drops silently, "decline" sends one polite refusal then goes +# silent toward that sender for gateway.pairing.DECLINE_DEDUPE_SECONDS (#88028). +UNAUTHORIZED_DM_BEHAVIORS = {"pair", "ignore", "decline"} +DEFAULT_UNAUTHORIZED_DM_DECLINE_MESSAGE = ( + "Hi! I'm a personal assistant and can only chat with my owner, so I can't help you directly. Sorry!" +) + + def _normalize_choice(value: Any, choices: set, default: str) -> str: """Lower-cased *value* when it is one of *choices*, else *default*.""" normalized = value.strip().lower() if isinstance(value, str) else None @@ -561,7 +569,8 @@ class GatewayConfig: loop_watchdog_probe_interval_s: float = DEFAULT_LOOP_WATCHDOG_INTERVAL_S loop_watchdog_probe_timeout_s: float = DEFAULT_LOOP_WATCHDOG_TIMEOUT_S loop_watchdog_max_strikes: int = DEFAULT_LOOP_WATCHDOG_MAX_STRIKES - unauthorized_dm_behavior: str = "pair" # "pair" or "ignore" + unauthorized_dm_behavior: str = "pair" # UNAUTHORIZED_DM_BEHAVIORS + unauthorized_dm_decline_message: str = "" # "decline" reply text; empty → DEFAULT_UNAUTHORIZED_DM_DECLINE_MESSAGE streaming: StreamingConfig = field(default_factory=StreamingConfig) # Prune SessionEntry records older than this (a resumed chat gets a fresh session). 0 = off. session_store_max_age_days: int = 90 @@ -574,7 +583,7 @@ class GatewayConfig: "max_concurrent_sessions", "multiplex_profiles", "room_link_url", "systemd_watchdog_seconds", "loop_watchdog", "loop_watchdog_probe_interval_s", "loop_watchdog_probe_timeout_s", - "loop_watchdog_max_strikes", "unauthorized_dm_behavior", + "loop_watchdog_max_strikes", "unauthorized_dm_behavior", "unauthorized_dm_decline_message", ) def __post_init__(self) -> None: @@ -720,7 +729,8 @@ class GatewayConfig: loop_watchdog_probe_timeout_s=bounded_float("loop_watchdog_probe_timeout_s", DEFAULT_LOOP_WATCHDOG_TIMEOUT_S, 1.0, 600.0), loop_watchdog_max_strikes=max_strikes, max_concurrent_sessions=max_concurrent_sessions, - unauthorized_dm_behavior=_normalize_choice(data.get("unauthorized_dm_behavior"), {"pair", "ignore"}, "pair"), + unauthorized_dm_behavior=_normalize_choice(data.get("unauthorized_dm_behavior"), UNAUTHORIZED_DM_BEHAVIORS, "pair"), + unauthorized_dm_decline_message=str(data.get("unauthorized_dm_decline_message") or "").strip(), streaming=StreamingConfig.from_dict(data.get("streaming", {})), session_store_max_age_days=session_store_max_age_days, profile_routes=parse_profile_routes(data.get("profile_routes") or []), @@ -736,7 +746,7 @@ class GatewayConfig: def get_unauthorized_dm_behavior(self, platform: Optional[Platform] = None) -> str: """Effective unauthorized-DM behavior. Email is inbox-shaped so it defaults to ``"ignore"`` unless its own ``unauthorized_dm_behavior`` opts in (a global default does not).""" - choice = self._extra_choice(platform, "unauthorized_dm_behavior", {"pair", "ignore"}, self.unauthorized_dm_behavior) + choice = self._extra_choice(platform, "unauthorized_dm_behavior", UNAUTHORIZED_DM_BEHAVIORS, self.unauthorized_dm_behavior) if choice is not None: return choice return "ignore" if platform == Platform.EMAIL else self.unauthorized_dm_behavior diff --git a/gateway/pairing.py b/gateway/pairing.py index 1a736c4955..989153b351 100644 --- a/gateway/pairing.py +++ b/gateway/pairing.py @@ -34,6 +34,7 @@ RATE_LIMIT_SECONDS = 600 # 1 request per user per 10 minutes LOCKOUT_SECONDS = 3600 # Lockout duration after too many failures MAX_PENDING_PER_PLATFORM = 3 # Max pending codes per platform MAX_FAILED_ATTEMPTS = 5 # Failed approvals before lockout +DECLINE_DEDUPE_SECONDS = 24 * 3600 # One polite decline per (platform, sender) per window (#88028) # Default pairing directory override. Deliberately ``None``: an eagerly computed # path would freeze the HERMES_HOME/profile context at gateway boot, ignoring later @@ -349,6 +350,9 @@ class PairingStore: def _rate_limit_path(self) -> Path: return self._dir / "_rate_limits.json" + def _decline_stamp_path(self) -> Path: + return self._dir / "_declined.json" + def _cleanup_expired(self, platform: str) -> None: """Remove expired pending codes; malformed/legacy entries (no numeric ``created_at``) count as expired.""" path = self._pending_path(platform) @@ -556,6 +560,30 @@ class PairingStore: limits[f"{platform}:{alias}"] = now self._save_limits(limits) + # ----- "decline" unauthorized-DM behavior (#88028) ----- + + def has_recent_decline(self, platform: str, user_id: str) -> bool: + """Whether this sender (under any alias) was sent a polite decline within DECLINE_DEDUPE_SECONDS.""" + stamps = self._load_json(self._decline_stamp_path()) + now = time.time() + return any( + isinstance(stamped := stamps.get(f"{platform}:{alias}"), (int, float)) and (now - stamped) < DECLINE_DEDUPE_SECONDS + for alias in _user_id_aliases(platform, user_id) + ) + + def record_decline(self, platform: str, user_id: str) -> None: + """Stamp the sender (all aliases) and prune expired stamps. Callers stamp BEFORE sending so a + delivery failure cannot become a decline storm on the sender's next message.""" + with self._lock: + now = time.time() + stamps = { + key: value for key, value in self._load_json(self._decline_stamp_path()).items() + if isinstance(value, (int, float)) and (now - value) < DECLINE_DEDUPE_SECONDS + } + for alias in _user_id_aliases(platform, user_id): + stamps[f"{platform}:{alias}"] = now + self._save_json(self._decline_stamp_path(), stamps) + def _is_locked_out(self, platform: str) -> bool: return time.time() < self._limits().get(f"_lockout:{platform}", 0) diff --git a/gateway/run_inbound.py b/gateway/run_inbound.py index 3c5028cb61..7cb87ac6b9 100644 --- a/gateway/run_inbound.py +++ b/gateway/run_inbound.py @@ -109,6 +109,26 @@ class GatewayInboundMixin: # Record rate limit so subsequent messages are silently ignored pairing_store._record_rate_limit(platform_name, source.user_id) + 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 + 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" + pairing_store = self._pairing_store_for(source) + if pairing_store is None or pairing_store.has_recent_decline(platform_name, source.user_id): + return + pairing_store.record_decline(platform_name, source.user_id) + adapter = self._adapter_for_source(source) + if not adapter: + return + config = getattr(self, "config", None) + text = str(getattr(config, "unauthorized_dm_decline_message", "") or "").strip() + try: + await adapter.send(source.chat_id, text or DEFAULT_UNAUTHORIZED_DM_DECLINE_MESSAGE) + except Exception: + logger.warning("Failed to deliver unauthorized-DM decline on %s", platform_name, exc_info=True) + async def _hm_admit_event( self, event: "MessageEvent" ) -> Optional[Tuple["MessageEvent", SessionSource, bool]]: @@ -195,13 +215,14 @@ class GatewayInboundMixin: logger.debug("Ignoring message with no user_id from %s", source.platform.value) return None logger.warning("Unauthorized user: %s (%s) on %s", source.user_id, source.user_name, source.platform.value) - # DMs get a pairing code, groups are ignored. A bot cannot pair, and answering one mid-cooldown is outbound traffic. - if ( - source.chat_type == "dm" - and not getattr(source, "is_bot", False) - and self._get_unauthorized_dm_behavior(source.platform, profile=source.profile) == "pair" - ): - await self._hm_offer_pairing_code(source) + # DMs get a pairing code or a one-time decline, groups are ignored. A bot cannot pair, and + # answering one mid-cooldown is outbound traffic. + if source.chat_type == "dm" and not getattr(source, "is_bot", False): + behavior = self._get_unauthorized_dm_behavior(source.platform, profile=source.profile) + if behavior == "pair": + await self._hm_offer_pairing_code(source) + elif behavior == "decline": + await self._hm_send_unauthorized_decline(source) return None # The busy path charged this event on arrival; a drained follow-up must not pay twice. if not getattr(event, "_bot_loop_admitted", False) and not self._admit_bot_message_for_source(source): diff --git a/tests/gateway/test_unauthorized_dm_behavior.py b/tests/gateway/test_unauthorized_dm_behavior.py index 2adc82b4da..1f4b7ee6a1 100644 --- a/tests/gateway/test_unauthorized_dm_behavior.py +++ b/tests/gateway/test_unauthorized_dm_behavior.py @@ -1,3 +1,4 @@ +import time from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock @@ -413,3 +414,62 @@ def test_qqbot_with_allowlist_ignores_unauthorized_dm(monkeypatch): behavior = runner._get_unauthorized_dm_behavior(Platform.QQBOT) assert behavior == "ignore" + + +# --------------------------------------------------------------------------- +# "decline" behavior: one-time polite decline instead of a pairing code +# (ported from qwibitai/nanoclaw#3260, #88028) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_unauthorized_dm_decline_sends_once_then_stays_silent(monkeypatch): + """First DM: stamp recorded BEFORE the send, custom text delivered, no pairing code. A sender + with a recent stamp gets nothing.""" + _clear_auth_env(monkeypatch) + config = GatewayConfig( + platforms={Platform.WHATSAPP: PlatformConfig(enabled=True, extra={"unauthorized_dm_behavior": "decline"})}, + ) + config.unauthorized_dm_decline_message = "Sorry, this assistant is private." + runner, adapter = _make_runner(Platform.WHATSAPP, config) + jid = "15551234567@s.whatsapp.net" + runner.pairing_store.has_recent_decline.return_value = False + + assert await runner._handle_message(_make_event(Platform.WHATSAPP, jid, jid)) is None + + runner.pairing_store.generate_code.assert_not_called() + runner.pairing_store.record_decline.assert_called_once_with("whatsapp", jid) + adapter.send.assert_awaited_once_with(jid, "Sorry, this assistant is private.") + + runner.pairing_store.has_recent_decline.return_value = True + adapter.send.reset_mock() + runner.pairing_store.record_decline.reset_mock() + + assert await runner._handle_message(_make_event(Platform.WHATSAPP, jid, jid)) is None + + runner.pairing_store.record_decline.assert_not_called() + adapter.send.assert_not_awaited() + + +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.""" + from unittest.mock import patch as _patch + + import gateway.pairing as pairing_mod + + config = GatewayConfig.from_dict( + {"unauthorized_dm_behavior": "DECLINE", "unauthorized_dm_decline_message": " custom text "} + ) + assert config.unauthorized_dm_behavior == "decline" + assert config.get_unauthorized_dm_behavior(Platform.TELEGRAM) == "decline" + assert config.to_dict()["unauthorized_dm_behavior"] == "decline" + assert GatewayConfig.from_dict(config.to_dict()).unauthorized_dm_decline_message == "custom text" + + with _patch("gateway.pairing.PAIRING_DIR", tmp_path): + store = pairing_mod.PairingStore() + assert store.has_recent_decline("telegram", "12345") is False + store.record_decline("telegram", "12345") + assert store.has_recent_decline("telegram", "12345") is True + assert store.has_recent_decline("telegram", "67890") is False + with _patch("gateway.pairing.time.time", return_value=time.time() + pairing_mod.DECLINE_DEDUPE_SECONDS + 1): + assert store.has_recent_decline("telegram", "12345") is False diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index a959f6aa4c..5ad3c1bf8d 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -2422,6 +2422,13 @@ whatsapp: - `pair` is the default for chat-style DM platforms. Hermes denies access, but replies with a one-time pairing code in DMs. - `ignore` silently drops unauthorized DMs. +- `decline` sends one short, polite decline instead of a pairing code, then stays silent toward that sender for 24 hours. Override the default text: + + ```yaml + unauthorized_dm_behavior: decline + unauthorized_dm_decline_message: "Sorry, this assistant is private." + ``` + - 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. diff --git a/website/docs/user-guide/security.md b/website/docs/user-guide/security.md index 8a69a1684b..c221ec0fd2 100644 --- a/website/docs/user-guide/security.md +++ b/website/docs/user-guide/security.md @@ -441,6 +441,7 @@ whatsapp: - `pair` is the default for chat-style DM platforms. Unauthorized DMs get a pairing code reply. - `ignore` silently drops unauthorized DMs. +- `decline` sends one short, polite decline ("I can only chat with my owner") instead of a pairing code, then ignores further messages from that sender for 24 hours. Customize the text with `unauthorized_dm_decline_message`. - 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 on Telegram while keeping WhatsApp silent.