fix(gateway): 'decline' survives the config.yaml load path; Telegram forwards it; wizard offers it

config_loader._dm_behavior_choice still normalized against {"pair","ignore"},
so `unauthorized_dm_behavior: decline` in config.yaml (top level or a
platform block) was coerced back to "pair" on the real startup path
(load_gateway_config), and `unauthorized_dm_decline_message` was never
bridged into gw_data. Both now go through gateway.config.UNAUTHORIZED_DM_BEHAVIORS
(single source) and the presence bridge. The round-trip test exercises
load_gateway_config with a real config.yaml (top-level decline, telegram
override, custom message) instead of GatewayConfig.from_dict.

Telegram's intake prefilter only forwarded unauthorized DMs when the
behavior was exactly "pair", so with an allowlist configured a decline was
never sent. Anything that needs an outbound reply (!= "ignore") passes.

`hermes gateway setup` gains a "Politely decline unknown senders" choice
that writes platforms.<platform>.unauthorized_dm_behavior: decline; docs
mention it. Upstream-source references dropped from docstrings.
This commit is contained in:
teknium1
2026-09-14 21:13:39 -07:00
committed by Teknium
parent a6e934e0fd
commit fd303c0137
6 changed files with 42 additions and 19 deletions
+3 -2
View File
@@ -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"),
)
+1 -1
View File
@@ -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"
+10 -4
View File
@@ -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 <platform> <code>")
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"]:
+5 -4
View File
@@ -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.
+21 -8
View File
@@ -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):
+2
View File
@@ -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.<platform>.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.