From d481d5fb1a10025edab0520462eda56cfc8a3426 Mon Sep 17 00:00:00 2001 From: "w.antar" Date: Sun, 21 Jun 2026 14:10:29 +0100 Subject: [PATCH] fix(pairing): normalize sender IDs to str in the pairing store --- nanobot/pairing/store.py | 16 ++++++++++------ tests/pairing/test_store.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/nanobot/pairing/store.py b/nanobot/pairing/store.py index 37ac2f4f..7ea7e18c 100644 --- a/nanobot/pairing/store.py +++ b/nanobot/pairing/store.py @@ -44,9 +44,12 @@ def _load() -> dict[str, Any]: logger.warning("Corrupted pairing store, resetting") return {"approved": {}, "pending": {}} - # Convert approved lists to sets for O(1) lookup + # Convert approved lists to sets for O(1) lookup. Sender IDs are normalized + # to str so lookups match is_approved()/revoke(), which coerce with str(): + # IDs may be numeric (e.g. Telegram/QQ) in code or in a hand-edited + # pairing.json, and an int entry would never match the str() lookup. for channel, users in data.get("approved", {}).items(): - data["approved"][channel] = set(users) + data["approved"][channel] = {str(u) for u in users} return data @@ -87,7 +90,7 @@ def generate_code( data.setdefault("pending", {})[code] = { "channel": channel, - "sender_id": sender_id, + "sender_id": str(sender_id), "created_at": time.time(), "expires_at": time.time() + ttl, } @@ -162,12 +165,13 @@ def revoke(channel: str, sender_id: str) -> bool: data = _load() approved: dict[str, set[str]] = data.get("approved", {}) users = approved.get(channel, set()) - if sender_id in users: - users.discard(sender_id) + sid = str(sender_id) + if sid in users: + users.discard(sid) if not users: del approved[channel] _save(data) - logger.info("Revoked {} from {}", sender_id, channel) + logger.info("Revoked {} from {}", sid, channel) return True return False diff --git a/tests/pairing/test_store.py b/tests/pairing/test_store.py index 6e0f8ba9..de4c8c6c 100644 --- a/tests/pairing/test_store.py +++ b/tests/pairing/test_store.py @@ -174,6 +174,34 @@ class TestHandlePairingCommand: assert "Pending pairing requests:" in reply +class TestNonStringSenderId: + """Sender IDs may be numeric (e.g. Telegram/QQ). The store normalizes them + to str so writes/reads/removals stay consistent with is_approved().""" + + def test_numeric_sender_id_round_trip(self) -> None: + code = store.generate_code("telegram", 12345) + assert store.approve_code(code) == ("telegram", "12345") + # Approved regardless of whether the caller passes int or str. + assert store.is_approved("telegram", 12345) is True + assert store.is_approved("telegram", "12345") is True + assert store.get_approved("telegram") == ["12345"] + # Revoke also works with a numeric id. + assert store.revoke("telegram", 12345) is True + assert store.is_approved("telegram", "12345") is False + + def test_numeric_id_in_hand_edited_store(self) -> None: + # Operators may edit pairing.json directly; a numeric entry must still + # match the str() lookup that is_approved() performs. + store._store_path().write_text( + '{"approved": {"telegram": [12345]}, "pending": {}}', + encoding="utf-8", + ) + assert store.is_approved("telegram", "12345") is True + assert store.is_approved("telegram", 12345) is True + assert store.revoke("telegram", 12345) is True + assert store.is_approved("telegram", "12345") is False + + class TestStoreDurability: def test_corruption_recovery(self, tmp_path, monkeypatch) -> None: path = tmp_path / "pairing.json"