refactor(pairing): move /pairing from BaseChannel to CommandRouter
/pairing is now a first-class built-in command dispatched through CommandRouter, just like /status, /model, /dream, etc. Benefits: - WebUI automatically shows /pairing in the slash command palette (because builtin_command_palette() feeds /api/commands). - All channels (Telegram, Discord, WebSocket, etc.) use the same dispatch path for /pairing; no more channel-level interception. - The command still only works for already-authorised users because is_allowed() gates message ingestion before the bus. Changes: - Add handle_pairing_command() to nanobot.pairing.store — pure function callable from CLI, CommandRouter, and tests. - Add cmd_pairing to nanobot.command.builtin and register in BUILTIN_COMMAND_SPECS + register_builtin_commands(). - Remove BaseChannel._handle_pairing_command() and the /pairing interception logic from _handle_message(). - Clean up unused pairing imports from base.py. - Add unit tests for handle_pairing_command and cmd_pairing dispatch.
This commit is contained in:
@@ -85,51 +85,3 @@ async def test_handle_message_group_ignores_unknown() -> None:
|
||||
|
||||
assert channel._sent == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_pairing_command_list(monkeypatch) -> None:
|
||||
channel = _DummyChannel({"allowFrom": ["owner"]}, MessageBus())
|
||||
monkeypatch.setattr(
|
||||
"nanobot.channels.base.list_pending",
|
||||
lambda: [
|
||||
{
|
||||
"code": "ABCD-EFGH",
|
||||
"channel": "dummy",
|
||||
"sender_id": "123",
|
||||
"expires_at": 9999999999,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
await channel._handle_pairing_command("owner", "chat1", "list")
|
||||
|
||||
assert len(channel._sent) == 1
|
||||
assert "ABCD-EFGH" in channel._sent[0].content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_pairing_command_approve(monkeypatch) -> None:
|
||||
channel = _DummyChannel({"allowFrom": ["owner"]}, MessageBus())
|
||||
monkeypatch.setattr(
|
||||
"nanobot.channels.base.approve_code",
|
||||
lambda code: ("dummy", "123") if code == "ABCD-EFGH" else None,
|
||||
)
|
||||
|
||||
await channel._handle_pairing_command("owner", "chat1", "approve ABCD-EFGH")
|
||||
|
||||
assert len(channel._sent) == 1
|
||||
assert "Approved" in channel._sent[0].content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_pairing_command_revoke(monkeypatch) -> None:
|
||||
channel = _DummyChannel({"allowFrom": ["owner"]}, MessageBus())
|
||||
monkeypatch.setattr(
|
||||
"nanobot.channels.base.revoke",
|
||||
lambda ch, sid: sid == "123",
|
||||
)
|
||||
|
||||
await channel._handle_pairing_command("owner", "chat1", "revoke 123")
|
||||
|
||||
assert len(channel._sent) == 1
|
||||
assert "Revoked" in channel._sent[0].content
|
||||
|
||||
@@ -26,11 +26,14 @@ class TestIsDispatchableCommand:
|
||||
assert router.is_dispatchable_command("/dream")
|
||||
assert router.is_dispatchable_command("/dream-log")
|
||||
assert router.is_dispatchable_command("/dream-restore")
|
||||
assert router.is_dispatchable_command("/pairing")
|
||||
|
||||
def test_prefix_commands_match(self, router: CommandRouter) -> None:
|
||||
assert router.is_dispatchable_command("/dream-log abc123")
|
||||
assert router.is_dispatchable_command("/dream-restore def456")
|
||||
assert router.is_dispatchable_command("/model fast")
|
||||
assert router.is_dispatchable_command("/pairing list")
|
||||
assert router.is_dispatchable_command("/pairing approve CODE")
|
||||
|
||||
def test_priority_commands_not_matched(self, router: CommandRouter) -> None:
|
||||
# Priority commands are NOT in the dispatchable tiers — they are
|
||||
@@ -46,9 +49,11 @@ class TestIsDispatchableCommand:
|
||||
def test_case_insensitive(self, router: CommandRouter) -> None:
|
||||
assert router.is_dispatchable_command("/NEW")
|
||||
assert router.is_dispatchable_command("/Help")
|
||||
assert router.is_dispatchable_command("/PAIRING")
|
||||
|
||||
def test_strips_whitespace(self, router: CommandRouter) -> None:
|
||||
assert router.is_dispatchable_command(" /new ")
|
||||
assert router.is_dispatchable_command(" /pairing list ")
|
||||
|
||||
def test_unknown_slash_command_not_matched(self, router: CommandRouter) -> None:
|
||||
assert not router.is_dispatchable_command("/unknown")
|
||||
@@ -143,3 +148,82 @@ class TestMidTurnCommandDispatchedDirectly:
|
||||
)
|
||||
result = await router.dispatch(ctx)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestPairingCommandDispatch:
|
||||
"""Verify /pairing works via CommandRouter."""
|
||||
|
||||
@pytest.fixture()
|
||||
def router(self) -> CommandRouter:
|
||||
r = CommandRouter()
|
||||
register_builtin_commands(r)
|
||||
return r
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_msg(self) -> MagicMock:
|
||||
msg = MagicMock()
|
||||
msg.channel = "telegram"
|
||||
msg.chat_id = "chat1"
|
||||
msg.content = "/pairing list"
|
||||
msg.metadata = {}
|
||||
return msg
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pairing_list_dispatched(
|
||||
self, router: CommandRouter, fake_msg: MagicMock, monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"nanobot.pairing.store.list_pending",
|
||||
lambda: [
|
||||
{
|
||||
"code": "ABCD-EFGH",
|
||||
"channel": "telegram",
|
||||
"sender_id": "123",
|
||||
"expires_at": 9999999999,
|
||||
}
|
||||
],
|
||||
)
|
||||
ctx = CommandContext(
|
||||
msg=fake_msg, session=None,
|
||||
key="telegram:chat1", raw="/pairing list", args="list", loop=MagicMock(),
|
||||
)
|
||||
result = await router.dispatch(ctx)
|
||||
assert result is not None
|
||||
assert "ABCD-EFGH" in result.content
|
||||
assert result.metadata.get("_pairing_command") is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pairing_approve_dispatched(
|
||||
self, router: CommandRouter, fake_msg: MagicMock, monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"nanobot.pairing.store.approve_code",
|
||||
lambda code: ("telegram", "123") if code == "ABCD-EFGH" else None,
|
||||
)
|
||||
fake_msg.content = "/pairing approve ABCD-EFGH"
|
||||
ctx = CommandContext(
|
||||
msg=fake_msg, session=None,
|
||||
key="telegram:chat1", raw="/pairing approve ABCD-EFGH",
|
||||
args="approve ABCD-EFGH", loop=MagicMock(),
|
||||
)
|
||||
result = await router.dispatch(ctx)
|
||||
assert result is not None
|
||||
assert "Approved" in result.content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pairing_revoke_dispatched(
|
||||
self, router: CommandRouter, fake_msg: MagicMock, monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"nanobot.pairing.store.revoke",
|
||||
lambda ch, sid: sid == "123",
|
||||
)
|
||||
fake_msg.content = "/pairing revoke 123"
|
||||
ctx = CommandContext(
|
||||
msg=fake_msg, session=None,
|
||||
key="telegram:chat1", raw="/pairing revoke 123",
|
||||
args="revoke 123", loop=MagicMock(),
|
||||
)
|
||||
result = await router.dispatch(ctx)
|
||||
assert result is not None
|
||||
assert "Revoked" in result.content
|
||||
|
||||
@@ -88,6 +88,76 @@ class TestListPending:
|
||||
assert store.list_pending() == []
|
||||
|
||||
|
||||
class TestHandlePairingCommand:
|
||||
def test_list_empty(self) -> None:
|
||||
reply = store.handle_pairing_command("telegram", "list")
|
||||
assert reply == "No pending pairing requests."
|
||||
|
||||
def test_list_pending(self) -> None:
|
||||
store.generate_code("telegram", "123")
|
||||
reply = store.handle_pairing_command("telegram", "list")
|
||||
assert "Pending pairing requests:" in reply
|
||||
assert "telegram" in reply
|
||||
assert "123" in reply
|
||||
|
||||
def test_approve(self) -> None:
|
||||
code = store.generate_code("telegram", "123")
|
||||
reply = store.handle_pairing_command("telegram", f"approve {code}")
|
||||
assert "Approved" in reply
|
||||
assert "123" in reply
|
||||
assert store.is_approved("telegram", "123") is True
|
||||
|
||||
def test_approve_invalid(self) -> None:
|
||||
reply = store.handle_pairing_command("telegram", "approve BAD-CODE")
|
||||
assert "Invalid or expired" in reply
|
||||
|
||||
def test_approve_no_arg(self) -> None:
|
||||
reply = store.handle_pairing_command("telegram", "approve")
|
||||
assert "Usage:" in reply
|
||||
|
||||
def test_deny(self) -> None:
|
||||
code = store.generate_code("telegram", "123")
|
||||
reply = store.handle_pairing_command("telegram", f"deny {code}")
|
||||
assert "Denied" in reply
|
||||
assert store.approve_code(code) is None
|
||||
|
||||
def test_deny_unknown(self) -> None:
|
||||
reply = store.handle_pairing_command("telegram", "deny BAD-CODE")
|
||||
assert "not found" in reply
|
||||
|
||||
def test_revoke_current_channel(self) -> None:
|
||||
code = store.generate_code("telegram", "123")
|
||||
store.approve_code(code)
|
||||
reply = store.handle_pairing_command("telegram", "revoke 123")
|
||||
assert "Revoked" in reply
|
||||
assert store.is_approved("telegram", "123") is False
|
||||
|
||||
def test_revoke_other_channel(self) -> None:
|
||||
code = store.generate_code("discord", "456")
|
||||
store.approve_code(code)
|
||||
# Two-arg form: first arg is channel, second is user
|
||||
reply = store.handle_pairing_command("telegram", "revoke discord 456")
|
||||
assert "Revoked" in reply
|
||||
assert store.is_approved("discord", "456") is False
|
||||
|
||||
def test_revoke_unknown(self) -> None:
|
||||
reply = store.handle_pairing_command("telegram", "revoke 999")
|
||||
assert "was not in the approved list" in reply
|
||||
|
||||
def test_revoke_no_arg(self) -> None:
|
||||
reply = store.handle_pairing_command("telegram", "revoke")
|
||||
assert "Usage:" in reply
|
||||
|
||||
def test_unknown_subcommand(self) -> None:
|
||||
reply = store.handle_pairing_command("telegram", "foo")
|
||||
assert "Unknown pairing command" in reply
|
||||
|
||||
def test_default_to_list(self) -> None:
|
||||
store.generate_code("telegram", "123")
|
||||
reply = store.handle_pairing_command("telegram", "")
|
||||
assert "Pending pairing requests:" in reply
|
||||
|
||||
|
||||
class TestStoreDurability:
|
||||
def test_corruption_recovery(self, tmp_path, monkeypatch) -> None:
|
||||
path = tmp_path / "pairing.json"
|
||||
|
||||
Reference in New Issue
Block a user