feat(pairing): chat-native DM sender approval

Replace the file-editing onboarding workflow with a chat-native pairing flow:

- New pairing store (nanobot/pairing/store.py) persists approved senders
  and pending codes in ~/.nanobot/pairing.json.
- DM messages from unknown senders receive a short pairing code instead of
  silent denial. Group chats remain silently ignored.
- Existing allowFrom semantics are fully preserved; approved pairing users
  are merged at runtime so no config migration is needed.
- nanobot pairing list/approve/deny/revoke CLI commands for bootstrap and
  emergency management.
- /pairing slash commands intercepted in-channel so owners can approve
  senders without leaving the chat.
- is_dm flag added to BaseChannel._handle_message; Telegram, Discord and
  WebSocket updated to pass it.

Closes #3768
This commit is contained in:
chengyongru
2026-05-15 15:46:44 +08:00
committed by Xubin Ren
parent c10ec6094e
commit 4c4a9ae590
7 changed files with 415 additions and 10 deletions
+126 -10
View File
@@ -10,6 +10,14 @@ from loguru import logger
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.pairing import (
approve_code,
deny_code,
generate_code,
is_approved,
list_pending,
revoke,
)
class BaseChannel(ABC):
@@ -176,7 +184,14 @@ class BaseChannel(ABC):
return bool(streaming) and type(self).send_delta is not BaseChannel.send_delta
def is_allowed(self, sender_id: str) -> bool:
"""Check if *sender_id* is permitted. Empty list → deny all; ``"*"`` → allow all."""
"""Check if *sender_id* is permitted.
Priority:
1. ``allowFrom: ["*"]`` → allow all.
2. ``allowFrom`` list → allow if sender_id is present.
3. Pairing store approved list → allow if previously approved.
4. Otherwise deny.
"""
if isinstance(self.config, dict):
if "allow_from" in self.config:
allow_list = self.config.get("allow_from")
@@ -184,12 +199,13 @@ class BaseChannel(ABC):
allow_list = self.config.get("allowFrom", [])
else:
allow_list = getattr(self.config, "allow_from", [])
if not allow_list:
self.logger.warning("allow_from is empty — all access denied")
return False
if "*" in allow_list:
return True
return str(sender_id) in allow_list
if str(sender_id) in allow_list:
return True
if is_approved(self.name, str(sender_id)):
return True
return False
async def _handle_message(
self,
@@ -199,11 +215,14 @@ class BaseChannel(ABC):
media: list[str] | None = None,
metadata: dict[str, Any] | None = None,
session_key: str | None = None,
is_dm: bool = False,
) -> None:
"""
Handle an incoming message from the chat platform.
This method checks permissions and forwards to the bus.
For DM messages from unrecognised senders, a pairing code is
issued instead of silently dropping the message.
Args:
sender_id: The sender's identifier.
@@ -212,13 +231,39 @@ class BaseChannel(ABC):
media: Optional list of media URLs.
metadata: Optional channel-specific metadata.
session_key: Optional session key override (e.g. thread-scoped sessions).
is_dm: Whether the message is a direct / private message.
"""
if not self.is_allowed(sender_id):
self.logger.warning(
"Access denied for sender {}. "
"Add them to allowFrom list in config to grant access.",
sender_id,
)
if is_dm:
code = generate_code(self.name, str(sender_id))
reply = (
"This assistant requires approval before it can respond.\n"
f"Your pairing code is: `{code}`\n"
f"Ask the owner to run: `nanobot pairing approve {code}`"
)
await self.send(
OutboundMessage(
channel=self.name,
chat_id=str(chat_id),
content=reply,
metadata={"_pairing_code": code},
)
)
self.logger.info(
"Sent pairing code {} to sender {} in chat {}",
code, sender_id, chat_id,
)
else:
self.logger.warning(
"Access denied for sender {}. "
"Add them to allowFrom list in config to grant access.",
sender_id,
)
return
# Intercept /pairing slash commands before they reach the agent loop
if content.strip().startswith("/pairing"):
await self._handle_pairing_command(sender_id, chat_id, content.strip())
return
meta = metadata or {}
@@ -237,6 +282,77 @@ class BaseChannel(ABC):
await self.bus.publish_inbound(msg)
async def _handle_pairing_command(
self, sender_id: str, chat_id: str, content: str
) -> None:
"""Execute a ``/pairing`` slash command and reply directly to the user."""
parts = content.split()
sub = parts[1] if len(parts) > 1 else "list"
arg = parts[2] if len(parts) > 2 else None
if sub in ("list",):
pending = list_pending()
if not pending:
reply = "No pending pairing requests."
else:
lines = ["Pending pairing requests:"]
import time
for item in pending:
remaining = int(item.get("expires_at", 0) - time.time())
expiry = f"{remaining}s" if remaining > 0 else "expired"
lines.append(
f"- `{item['code']}` | {item['channel']} | {item['sender_id']} | {expiry}"
)
reply = "\n".join(lines)
elif sub == "approve":
if arg is None:
reply = "Usage: `/pairing approve <code>`"
else:
result = approve_code(arg)
if result is None:
reply = f"Invalid or expired pairing code: `{arg}`"
else:
channel, sid = result
reply = (
f"Approved pairing code `{arg}` — "
f"{sid} can now access {channel}"
)
elif sub == "deny":
if arg is None:
reply = "Usage: `/pairing deny <code>`"
else:
if deny_code(arg):
reply = f"Denied pairing code `{arg}`"
else:
reply = f"Pairing code `{arg}` not found or already expired"
elif sub == "revoke":
if arg is None:
reply = "Usage: `/pairing revoke <user_id>`"
else:
if revoke(self.name, arg):
reply = f"Revoked {arg} from {self.name}"
else:
reply = f"{arg} was not in the approved list for {self.name}"
else:
reply = (
"Unknown pairing command.\n"
"Usage: `/pairing [list|approve <code>|deny <code>|revoke <user_id>]`"
)
await self.send(
OutboundMessage(
channel=self.name,
chat_id=str(chat_id),
content=reply,
metadata={"_pairing_command": True},
)
)
@classmethod
def default_config(cls) -> dict[str, Any]:
"""Return default config for onboard. Override in plugins to auto-populate config.json."""