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:
@@ -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 = (
|
||||
|
||||
@@ -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.'
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user