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:
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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"]:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user