code-review fixes: fsync, entropy, is_dm propagation, tests

- Add os.fsync with Windows-compatible directory flush in pairing store
- Increase pairing code length from 6 -> 8 characters for higher entropy
- Remove SystemExit on empty allowFrom; empty list now defers to pairing
- Update is_allowed docstring to document pairing fallback semantics
- Propagate is_dm to Matrix (direct rooms) and Slack (im channels)
- Slack _is_allowed now checks pairing store for DM allowlist mode
- Fix /pairing revoke to accept optional channel argument
- Move inline import time to module top-level
- Add WebSocket comment explaining is_dm=True assumption
- Add comprehensive tests for store and BaseChannel pairing integration
- Fix existing tests that expected empty allowFrom to hard-exit

Refs #3774
This commit is contained in:
chengyongru
2026-05-15 15:46:44 +08:00
committed by Xubin Ren
parent 4c4a9ae590
commit f8e7e50759
9 changed files with 265 additions and 33 deletions
+11 -6
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import time
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any
@@ -191,6 +192,10 @@ class BaseChannel(ABC):
2. ``allowFrom`` list → allow if sender_id is present.
3. Pairing store approved list → allow if previously approved.
4. Otherwise deny.
An empty ``allowFrom`` list does not cause a hard exit; instead it
defers to the pairing store so that unknown DM senders can request
access via a pairing code.
"""
if isinstance(self.config, dict):
if "allow_from" in self.config:
@@ -296,8 +301,6 @@ class BaseChannel(ABC):
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"
@@ -331,12 +334,14 @@ class BaseChannel(ABC):
elif sub == "revoke":
if arg is None:
reply = "Usage: `/pairing revoke <user_id>`"
reply = "Usage: `/pairing revoke <user_id>` or `/pairing revoke <channel> <user_id>`"
else:
if revoke(self.name, arg):
reply = f"Revoked {arg} from {self.name}"
target_channel = parts[3] if len(parts) > 3 else self.name
target_user = arg if len(parts) <= 3 else parts[3]
if revoke(target_channel, target_user):
reply = f"Revoked {target_user} from {target_channel}"
else:
reply = f"{arg} was not in the approved list for {self.name}"
reply = f"{target_user} was not in the approved list for {target_channel}"
else:
reply = (
+2 -2
View File
@@ -143,9 +143,9 @@ class ChannelManager:
allow = cfg.get("allowFrom")
else:
allow = getattr(cfg, "allow_from", None)
if allow == []:
if allow is None:
raise SystemExit(
f'Error: "{name}" has empty allowFrom (denies all). '
f'Error: "{name}" is missing allowFrom. '
f'Set ["*"] to allow everyone, or add specific user IDs.'
)
+12 -9
View File
@@ -28,10 +28,11 @@ try:
RoomMessageMedia,
RoomMessageText,
RoomSendError,
RoomSendResponse,
RoomTypingError,
SyncError,
UploadError, RoomSendResponse,
)
UploadError,
)
from nio.crypto.attachments import decrypt_attachment
from nio.exceptions import EncryptionError
except ImportError as e:
@@ -107,7 +108,7 @@ class _StreamBuf:
:ivar text: Stores the text content of the buffer.
:type text: str
:ivar event_id: Identifier for the associated event. None indicates no
:ivar event_id: Identifier for the associated event. None indicates no
specific event association.
:type event_id: str | None
:ivar last_edit: Timestamp of the most recent edit to the buffer.
@@ -140,19 +141,19 @@ def _build_matrix_text_content(
) -> dict[str, object]:
"""
Constructs and returns a dictionary representing the matrix text content with optional
HTML formatting and reference to an existing event for replacement. This function is
HTML formatting and reference to an existing event for replacement. This function is
primarily used to create content payloads compatible with the Matrix messaging protocol.
:param text: The plain text content to include in the message.
:type text: str
:param event_id: Optional ID of the event to replace. If provided, the function will
include information indicating that the message is a replacement of the specified
:param event_id: Optional ID of the event to replace. If provided, the function will
include information indicating that the message is a replacement of the specified
event.
:type event_id: str | None
:param thread_relates_to: Optional Matrix thread relation metadata. For edits this is
stored in ``m.new_content`` so the replacement remains in the same thread.
:type thread_relates_to: dict[str, object] | None
:return: A dictionary containing the matrix text content, potentially enriched with
:return: A dictionary containing the matrix text content, potentially enriched with
HTML formatting and replacement metadata if applicable.
:rtype: dict[str, object]
"""
@@ -523,7 +524,7 @@ class MatrixChannel(BaseChannel):
return
await self._stop_typing_keepalive(chat_id, clear_typing=True)
content = _build_matrix_text_content(
buf.text,
buf.event_id,
@@ -537,7 +538,7 @@ class MatrixChannel(BaseChannel):
buf = _StreamBuf()
self._stream_bufs[chat_id] = buf
buf.text += delta
if not buf.text.strip():
return
@@ -870,6 +871,7 @@ class MatrixChannel(BaseChannel):
await self._handle_message(
sender_id=event.sender, chat_id=room.room_id,
content=event.body, metadata=self._base_metadata(room, event),
is_dm=self._is_direct_room(room),
)
except Exception:
await self._stop_typing_keepalive(room.room_id, clear_typing=True)
@@ -907,6 +909,7 @@ class MatrixChannel(BaseChannel):
content="\n".join(parts),
media=[attachment["path"]] if attachment else [],
metadata=meta,
is_dm=self._is_direct_room(room),
)
except Exception:
await self._stop_typing_keepalive(room.room_id, clear_typing=True)
+19 -1
View File
@@ -342,6 +342,22 @@ class SlackChannel(BaseChannel):
channel_type = event.get("channel_type") or ""
if not self._is_allowed(sender_id, chat_id, channel_type):
if channel_type == "im" and self.config.dm.enabled:
from nanobot.pairing import generate_code
code = generate_code(self.name, 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=chat_id,
content=reply,
metadata={"_pairing_code": code},
)
)
return
if channel_type != "im" and not self._should_respond_in_channel(event_type, text, chat_id):
@@ -608,11 +624,13 @@ class SlackChannel(BaseChannel):
self.logger.debug("done reaction failed: {}", e)
def _is_allowed(self, sender_id: str, chat_id: str, channel_type: str) -> bool:
from nanobot.pairing import is_approved
if channel_type == "im":
if not self.config.dm.enabled:
return False
if self.config.dm.policy == "allowlist":
return sender_id in self.config.dm.allow_from
return sender_id in self.config.dm.allow_from or is_approved(self.name, sender_id)
return True
# Group / channel messages
+2
View File
@@ -1249,6 +1249,8 @@ class WebSocketChannel(BaseChannel):
content = _parse_inbound_payload(raw)
if content is None:
continue
# WebSocket connections are always treated as 1:1 (DM) because
# each connection represents a single client browser/tab.
await self._handle_message(
sender_id=client_id,
chat_id=default_chat_id,