Merge pull request #74446 from NousResearch/bb/desktop-pairing
feat(pairing): profile-correct approvals, and a desktop surface to do them from
This commit is contained in:
+36
-15
@@ -33,7 +33,11 @@ from gateway.whatsapp_identity import (
|
||||
expand_whatsapp_aliases,
|
||||
normalize_whatsapp_identifier,
|
||||
)
|
||||
from hermes_constants import get_hermes_dir, get_hermes_home
|
||||
from hermes_constants import (
|
||||
get_default_hermes_root,
|
||||
get_hermes_dir,
|
||||
get_hermes_home,
|
||||
)
|
||||
from utils import atomic_replace
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -197,11 +201,15 @@ def _merge_pairing_dir(active_dir: Path, alternate_dir: Path) -> None:
|
||||
_secure_write(dest, json.dumps(merged, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
def _migrate_split_pairing_dirs() -> None:
|
||||
home = get_hermes_home()
|
||||
def _migrate_split_pairing_dirs(
|
||||
*,
|
||||
home: Optional[Path] = None,
|
||||
active: Optional[Path] = None,
|
||||
) -> None:
|
||||
home = home or get_hermes_home()
|
||||
old_dir = home / "pairing"
|
||||
new_dir = home / "platforms" / "pairing"
|
||||
active = PAIRING_DIR
|
||||
active = active or PAIRING_DIR
|
||||
alternate = new_dir if active.resolve() == old_dir.resolve() else old_dir
|
||||
_merge_pairing_dir(active, alternate)
|
||||
|
||||
@@ -241,26 +249,39 @@ class PairingStore:
|
||||
- {platform}-approved.json : approved (paired) users
|
||||
- _rate_limits.json : rate limit tracking
|
||||
|
||||
When constructed with ``profile="<name>"``, storage lives under
|
||||
``<HERMES_HOME>/profiles/<name>/pairing/`` (per-profile, used by
|
||||
multiplexing gateways so each profile has its own whitelist).
|
||||
Without a profile, storage is the global ``<HERMES_HOME>/pairing/``
|
||||
directory (backward-compat for the ``hermes pairing`` CLI).
|
||||
When constructed with ``profile="<name>"``, storage resolves from that
|
||||
profile's own HERMES_HOME using the same legacy/consolidated layout rules
|
||||
as ``hermes -p <name> pairing ...``. This keeps multiplex gateways and
|
||||
profile-scoped CLI approvals on one whitelist. Without a profile, storage
|
||||
is the global pairing directory for the current HERMES_HOME.
|
||||
"""
|
||||
|
||||
def __init__(self, profile: Optional[str] = None):
|
||||
# Resolve storage directory lazily — tests use a temp HERMES_HOME
|
||||
# and PairingStore may be constructed before the env is set.
|
||||
if profile:
|
||||
from hermes_constants import get_hermes_home
|
||||
self._dir = get_hermes_home() / "profiles" / profile / "pairing"
|
||||
root = get_default_hermes_root()
|
||||
profile_home = (
|
||||
root
|
||||
if profile == "default"
|
||||
else root / "profiles" / profile
|
||||
)
|
||||
self._dir = get_hermes_dir(
|
||||
"platforms/pairing",
|
||||
"pairing",
|
||||
home=profile_home,
|
||||
)
|
||||
else:
|
||||
self._dir = PAIRING_DIR
|
||||
self._dir.mkdir(parents=True, exist_ok=True)
|
||||
if not profile:
|
||||
if profile:
|
||||
# Explicit stores must resolve exactly as a standalone
|
||||
# ``hermes -p <profile> pairing ...`` process does. Merge the
|
||||
# alternate old/new layout so upgrades cannot split approvals.
|
||||
_migrate_split_pairing_dirs(home=profile_home, active=self._dir)
|
||||
else:
|
||||
# Heal installs whose global pairing data ended up split across
|
||||
# the legacy and new directories (per-profile stores never had
|
||||
# the legacy/new split).
|
||||
# the legacy and new directories.
|
||||
_migrate_split_pairing_dirs()
|
||||
# Protects all read-modify-write cycles. The gateway runs multiple
|
||||
# platform adapters concurrently in threads sharing one PairingStore.
|
||||
@@ -727,7 +748,7 @@ class PairingStore:
|
||||
def _all_platforms(self, suffix: str) -> list:
|
||||
"""List all platforms that have data files of a given suffix."""
|
||||
platforms = []
|
||||
for f in PAIRING_DIR.iterdir():
|
||||
for f in self._dir.iterdir():
|
||||
if f.name.endswith(f"-{suffix}.json"):
|
||||
platform = f.name.replace(f"-{suffix}.json", "")
|
||||
if not platform.startswith("_"):
|
||||
|
||||
+27
-7
@@ -12486,11 +12486,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
served = [active] + sorted(self._profile_adapters.keys())
|
||||
# Per-profile PairingStores so authz_mixin can route pairing
|
||||
# checks to the right whitelist. The active profile gets a store
|
||||
# at its HERMES_HOME; additional served profiles get one under
|
||||
# profiles/<name>/pairing/. See gateway.pairing.PairingStore.
|
||||
# at its HERMES_HOME; additional served profiles resolve from
|
||||
# their own profile homes. See gateway.pairing.PairingStore.
|
||||
for name in served:
|
||||
if name and name not in self.pairing_stores:
|
||||
self.pairing_stores[name] = PairingStore(profile=name)
|
||||
self.pairing_stores[name] = (
|
||||
self.pairing_store
|
||||
if name == active
|
||||
else PairingStore(profile=name)
|
||||
)
|
||||
write_runtime_status(served_profiles=served)
|
||||
except Exception:
|
||||
logger.debug("could not record served_profiles", exc_info=True)
|
||||
@@ -13677,23 +13681,39 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
== "pair"
|
||||
):
|
||||
platform_name = source.platform.value if source.platform else "unknown"
|
||||
pairing_store = self._pairing_store_for(source)
|
||||
if pairing_store is None:
|
||||
logger.error(
|
||||
"Cannot offer pairing code on %s: no pairing store",
|
||||
platform_name,
|
||||
)
|
||||
return None
|
||||
# Rate-limit ALL pairing responses (code or rejection) to
|
||||
# prevent spamming the user with repeated messages when
|
||||
# multiple DMs arrive in quick succession.
|
||||
if self.pairing_store._is_rate_limited(platform_name, source.user_id):
|
||||
if pairing_store._is_rate_limited(platform_name, source.user_id):
|
||||
return None
|
||||
code = self.pairing_store.generate_code(
|
||||
code = pairing_store.generate_code(
|
||||
platform_name, source.user_id, source.user_name or ""
|
||||
)
|
||||
if code:
|
||||
adapter = self._adapter_for_source(source)
|
||||
if adapter:
|
||||
store_profile = getattr(pairing_store, "profile", None)
|
||||
profile_arg = (
|
||||
f"-p {store_profile} "
|
||||
if isinstance(store_profile, str)
|
||||
and store_profile
|
||||
and store_profile != "default"
|
||||
else ""
|
||||
)
|
||||
await adapter.send(
|
||||
source.chat_id,
|
||||
f"Hi~ I don't recognize you yet!\n\n"
|
||||
f"Here's your pairing code: `{code}`\n\n"
|
||||
f"Ask the bot owner to run:\n"
|
||||
f"`hermes pairing approve {platform_name} {code}`"
|
||||
f"`hermes {profile_arg}pairing approve "
|
||||
f"{platform_name} {code}`"
|
||||
)
|
||||
else:
|
||||
adapter = self._adapter_for_source(source)
|
||||
@@ -13704,7 +13724,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
"Please try again later!"
|
||||
)
|
||||
# Record rate limit so subsequent messages are silently ignored
|
||||
self.pairing_store._record_rate_limit(platform_name, source.user_id)
|
||||
pairing_store._record_rate_limit(platform_name, source.user_id)
|
||||
return None
|
||||
|
||||
# Intercept messages that are responses to a pending /update prompt.
|
||||
|
||||
Reference in New Issue
Block a user