fix(talk): address remaining PR #741 reviewer feedback
Closes the seven outstanding items from the @claude review on PR #741: 1. Add empty `tests/client/talk/__init__.py` for pytest discovery parity with `tests/client/{collectives,news}/`. 2. Standardise boolean query params to integers — `includeStatus` was the string `"true"` in `list_conversations`/`list_participants` while every other flag (`noStatusUpdate`, `lookIntoFuture`, `setReadMarker`, `includeLastKnown`) used `1`/`0`. 3. Replace the `app:install || app:enable` chain in the spreed install hook with `app:install --keep-disabled --force || true; app:enable spreed`, so unrelated install failures surface as a clear "app not found" from `app:enable` rather than being silently masked. 4. Add `_validate_token()` (alphanumeric whitelist) and call it from all six TalkClient methods that interpolate the token into a URL path — defence-in-depth against pathological tokens reaching httpx. 5. Rename `TalkConversation.type` to `room_type` with `Field(alias="type")` and `populate_by_name=True`, so the field no longer shadows Python's builtin while preserving spreed's wire format on input. MCP responses now serialize `room_type` (field name) instead of `type`. 6. `mark_as_read` now passes `json=body or None` so the bodyless "mark everything as read" call doesn't send a spurious `{}` body and `Content-Type: application/json` header. 7. `_validate_message_text` rejects whitespace-only messages, not just empty strings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
9614c0b361
commit
f075540232
@@ -12,6 +12,7 @@ application/json`` is sent.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from nextcloud_mcp_server.client.base import BaseNextcloudClient
|
||||
@@ -24,6 +25,18 @@ from nextcloud_mcp_server.models.talk import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Spreed conversation tokens are short alphanumeric strings (e.g. "a1b2c3d4").
|
||||
# httpx does not normalise path traversal sequences, so a pathological token
|
||||
# like ``"../foo"`` would be sent verbatim. Validate up-front for clearer
|
||||
# errors and defence-in-depth.
|
||||
_TALK_TOKEN_RE = re.compile(r"^[A-Za-z0-9]+$")
|
||||
|
||||
|
||||
def _validate_token(token: str) -> None:
|
||||
if not _TALK_TOKEN_RE.fullmatch(token):
|
||||
raise ValueError(f"Invalid Talk conversation token: {token!r}")
|
||||
|
||||
|
||||
class TalkClient(BaseNextcloudClient):
|
||||
"""Client for Nextcloud Talk (spreed) app operations."""
|
||||
|
||||
@@ -68,7 +81,7 @@ class TalkClient(BaseNextcloudClient):
|
||||
if modified_since is not None:
|
||||
params["modifiedSince"] = modified_since
|
||||
if include_status:
|
||||
params["includeStatus"] = "true"
|
||||
params["includeStatus"] = 1
|
||||
if no_status_update:
|
||||
params["noStatusUpdate"] = 1
|
||||
response = await self._make_request(
|
||||
@@ -79,6 +92,7 @@ class TalkClient(BaseNextcloudClient):
|
||||
|
||||
async def get_conversation(self, token: str) -> TalkConversation:
|
||||
"""Fetch a single Talk conversation by its room token."""
|
||||
_validate_token(token)
|
||||
response = await self._make_request(
|
||||
"GET", f"{self._ROOM_BASE}/{token}", headers=self._talk_headers()
|
||||
)
|
||||
@@ -112,6 +126,7 @@ class TalkClient(BaseNextcloudClient):
|
||||
|
||||
async def delete_conversation(self, token: str) -> None:
|
||||
"""Delete a conversation. Used by integration test cleanup."""
|
||||
_validate_token(token)
|
||||
await self._make_request(
|
||||
"DELETE", f"{self._ROOM_BASE}/{token}", headers=self._talk_headers()
|
||||
)
|
||||
@@ -154,6 +169,7 @@ class TalkClient(BaseNextcloudClient):
|
||||
if the header was absent or unparseable), suitable for
|
||||
pagination.
|
||||
"""
|
||||
_validate_token(token)
|
||||
clamped_limit = min(max(1, limit), 200)
|
||||
params: dict[str, Any] = {
|
||||
"limit": clamped_limit,
|
||||
@@ -208,6 +224,7 @@ class TalkClient(BaseNextcloudClient):
|
||||
silent: When True, the message is delivered without push
|
||||
notifications.
|
||||
"""
|
||||
_validate_token(token)
|
||||
body: dict[str, Any] = {"message": message}
|
||||
if reply_to is not None:
|
||||
body["replyTo"] = reply_to
|
||||
@@ -232,13 +249,17 @@ class TalkClient(BaseNextcloudClient):
|
||||
that message; otherwise spreed marks everything currently in the
|
||||
room as read.
|
||||
"""
|
||||
_validate_token(token)
|
||||
body: dict[str, Any] = {}
|
||||
if last_read_message is not None:
|
||||
body["lastReadMessage"] = last_read_message
|
||||
# ``json=None`` makes httpx skip both the body and the
|
||||
# ``Content-Type: application/json`` header — semantically correct
|
||||
# for the bodyless "mark everything as read" call.
|
||||
await self._make_request(
|
||||
"POST",
|
||||
f"{self._CHAT_BASE}/{token}/read",
|
||||
json=body,
|
||||
json=body or None,
|
||||
headers=self._talk_headers(),
|
||||
)
|
||||
|
||||
@@ -248,9 +269,10 @@ class TalkClient(BaseNextcloudClient):
|
||||
self, token: str, *, include_status: bool = False
|
||||
) -> list[TalkParticipant]:
|
||||
"""List participants of a Talk conversation."""
|
||||
_validate_token(token)
|
||||
params: dict[str, Any] = {}
|
||||
if include_status:
|
||||
params["includeStatus"] = "true"
|
||||
params["includeStatus"] = 1
|
||||
response = await self._make_request(
|
||||
"GET",
|
||||
f"{self._ROOM_BASE}/{token}/participants",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from .base import BaseResponse, StatusResponse
|
||||
|
||||
@@ -48,9 +48,25 @@ class TalkConversation(BaseModel):
|
||||
flows.
|
||||
"""
|
||||
|
||||
# ``populate_by_name=True`` lets us deserialize spreed's ``type`` key
|
||||
# into the ``room_type`` field while still allowing internal callers
|
||||
# to construct the model with ``room_type=...`` directly.
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
id: int
|
||||
token: str
|
||||
type: int
|
||||
# The spreed JSON wire format uses ``type`` for the room kind, but
|
||||
# ``type`` shadows Python's builtin within the class scope, which
|
||||
# would silently call this int field if anyone wrote ``type(...)``
|
||||
# in a validator or method on this model. Map to ``room_type`` and
|
||||
# alias the wire field instead.
|
||||
room_type: int = Field(
|
||||
alias="type",
|
||||
description=(
|
||||
"Conversation kind: 1=one-to-one, 2=group, 3=public, "
|
||||
"4=changelog, 5=former one-to-one, 6=note-to-self."
|
||||
),
|
||||
)
|
||||
name: str
|
||||
displayName: str
|
||||
description: str = ""
|
||||
|
||||
@@ -27,8 +27,10 @@ _MESSAGE_MAX_LENGTH = 32000
|
||||
|
||||
|
||||
def _validate_message_text(message: str) -> None:
|
||||
if not message:
|
||||
raise ValueError("Message text must not be empty")
|
||||
# Reject both empty strings and whitespace-only strings — spreed
|
||||
# would happily post the latter as a visually-blank message.
|
||||
if not message or not message.strip():
|
||||
raise ValueError("Message text must not be empty or whitespace-only")
|
||||
if len(message) > _MESSAGE_MAX_LENGTH:
|
||||
raise ValueError(
|
||||
f"Message too long: {len(message)} characters (max {_MESSAGE_MAX_LENGTH})"
|
||||
|
||||
Reference in New Issue
Block a user