fix: merge split gateway pairing stores

This commit is contained in:
williamumu 2026-05-24 00:56:27 +08:00 committed by Teknium
parent 2c5762f575
commit 8a7d0790df
2 changed files with 93 additions and 1 deletions

View file

@ -33,7 +33,7 @@ from gateway.whatsapp_identity import (
expand_whatsapp_aliases,
normalize_whatsapp_identifier,
)
from hermes_constants import get_hermes_dir
from hermes_constants import get_hermes_dir, get_hermes_home
from utils import atomic_replace
logger = logging.getLogger(__name__)
@ -161,6 +161,51 @@ def _sync_allowlist_remove(platform: str, user_id: str) -> None:
pass
def _load_json_file(path: Path) -> dict:
if path.exists():
try:
data = json.loads(path.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except (json.JSONDecodeError, OSError):
return {}
return {}
def _merge_pairing_dir(active_dir: Path, alternate_dir: Path) -> None:
"""Merge split legacy/new pairing data into the active PairingStore dir.
Older installs use ``{HERMES_HOME}/pairing`` while newer code/docs may
write ``{HERMES_HOME}/platforms/pairing``. If both directories exist, the
gateway must not silently ignore approved users sitting in the inactive
location; otherwise already-paired Feishu users get asked for a fresh code.
"""
if not alternate_dir.exists() or active_dir.resolve() == alternate_dir.resolve():
return
active_dir.mkdir(parents=True, exist_ok=True)
for src in alternate_dir.glob("*.json"):
if not src.is_file():
continue
dest = active_dir / src.name
merged = _load_json_file(src)
if not merged:
continue
current = _load_json_file(dest)
before = dict(current)
# Active data wins on key conflict; otherwise union the inactive data.
merged.update(current)
if merged != before:
_secure_write(dest, json.dumps(merged, indent=2, ensure_ascii=False))
def _migrate_split_pairing_dirs() -> None:
home = get_hermes_home()
old_dir = home / "pairing"
new_dir = home / "platforms" / "pairing"
active = PAIRING_DIR
alternate = new_dir if active.resolve() == old_dir.resolve() else old_dir
_merge_pairing_dir(active, alternate)
def _secure_write(path: Path, data: str) -> None:
"""Write data to file with restrictive permissions (owner read/write only).
@ -212,6 +257,11 @@ class PairingStore:
else:
self._dir = PAIRING_DIR
self._dir.mkdir(parents=True, exist_ok=True)
if not profile:
# Heal installs whose global pairing data ended up split across
# the legacy and new directories (per-profile stores never had
# the legacy/new split).
_migrate_split_pairing_dirs()
# Protects all read-modify-write cycles. The gateway runs multiple
# platform adapters concurrently in threads sharing one PairingStore.
self._lock = threading.RLock()

View file

@ -27,6 +27,48 @@ def _make_store(tmp_path):
return PairingStore()
class TestSplitPairingDirMigration:
def test_merges_new_approved_into_active_legacy_dir(self, tmp_path):
home = tmp_path / "home"
legacy = home / "pairing"
new = home / "platforms" / "pairing"
legacy.mkdir(parents=True)
new.mkdir(parents=True)
(new / "feishu-approved.json").write_text(json.dumps({
"ou_user": {"user_name": "Alice", "approved_at": 123.0}
}))
with patch("gateway.pairing.PAIRING_DIR", legacy), patch("gateway.pairing.get_hermes_home", return_value=home):
store = PairingStore()
assert store.is_approved("feishu", "ou_user") is True
migrated = json.loads((legacy / "feishu-approved.json").read_text())
assert "ou_user" in migrated
def test_active_entries_win_when_merging_split_dirs(self, tmp_path):
home = tmp_path / "home"
legacy = home / "pairing"
new = home / "platforms" / "pairing"
legacy.mkdir(parents=True)
new.mkdir(parents=True)
(legacy / "feishu-approved.json").write_text(json.dumps({
"ou_user": {"user_name": "Active", "approved_at": 2.0}
}))
(new / "feishu-approved.json").write_text(json.dumps({
"ou_user": {"user_name": "Inactive", "approved_at": 1.0},
"ou_other": {"user_name": "Other", "approved_at": 1.0},
}))
with patch("gateway.pairing.PAIRING_DIR", legacy), patch("gateway.pairing.get_hermes_home", return_value=home):
store = PairingStore()
assert store.is_approved("feishu", "ou_user") is True
assert store.is_approved("feishu", "ou_other") is True
migrated = json.loads((legacy / "feishu-approved.json").read_text())
assert migrated["ou_user"]["user_name"] == "Active"
assert migrated["ou_other"]["user_name"] == "Other"
# ---------------------------------------------------------------------------
# _secure_write
# ---------------------------------------------------------------------------