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>
172 lines
5.5 KiB
Python
172 lines
5.5 KiB
Python
"""Pydantic models for the Nextcloud Talk (spreed) integration."""
|
|
|
|
from typing import Any
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
|
|
from .base import BaseResponse, StatusResponse
|
|
|
|
# Domain models
|
|
|
|
|
|
class TalkMessage(BaseModel):
|
|
"""A single chat message in a Talk conversation.
|
|
|
|
See spreed docs/chat.md for the field definitions. We map only the
|
|
fields that are useful to MCP consumers; spreed returns more.
|
|
"""
|
|
|
|
id: int
|
|
token: str
|
|
actorType: str
|
|
actorId: str
|
|
actorDisplayName: str
|
|
timestamp: int
|
|
systemMessage: str = ""
|
|
messageType: str
|
|
message: str
|
|
messageParameters: dict[str, Any] = Field(default_factory=dict)
|
|
expirationTimestamp: int | None = None
|
|
referenceId: str | None = None
|
|
markdown: bool | None = None
|
|
|
|
@field_validator("messageParameters", mode="before")
|
|
@classmethod
|
|
def _coerce_empty_list_params(cls, v: Any) -> Any:
|
|
# spreed serializes an empty parameter map as `[]` (PHP array) rather
|
|
# than `{}`; normalize so pydantic accepts it as a dict.
|
|
if isinstance(v, list) and not v:
|
|
return {}
|
|
return v
|
|
|
|
|
|
class TalkConversation(BaseModel):
|
|
"""A Talk conversation (room).
|
|
|
|
See spreed docs/conversation.md for the full field reference. Many
|
|
optional fields are omitted; we keep the ones useful for chat-centric
|
|
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
|
|
# 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 = ""
|
|
participantType: int | None = None
|
|
unreadMessages: int = 0
|
|
unreadMention: bool = False
|
|
lastActivity: int | None = None
|
|
# spreed returns `0` (not `null`) when the user has not read any
|
|
# message in the room yet — compare to ``None`` explicitly rather
|
|
# than relying on truthiness, since `0` is falsy but valid.
|
|
lastReadMessage: int | None = None
|
|
lastMessage: TalkMessage | None = None
|
|
readOnly: int | None = None
|
|
isFavorite: bool | None = None
|
|
notificationLevel: int | None = None
|
|
objectType: str | None = None
|
|
objectId: str | None = None
|
|
|
|
@field_validator("lastMessage", mode="before")
|
|
@classmethod
|
|
def _coerce_empty_last_message(cls, v: Any) -> Any:
|
|
# spreed returns `lastMessage: []` (PHP empty array) when there has
|
|
# never been a message in the room; normalize to None.
|
|
if isinstance(v, list) and not v:
|
|
return None
|
|
return v
|
|
|
|
|
|
class TalkParticipant(BaseModel):
|
|
"""A participant (attendee) in a Talk conversation."""
|
|
|
|
attendeeId: int
|
|
actorType: str
|
|
actorId: str
|
|
displayName: str
|
|
participantType: int
|
|
inCall: int = 0
|
|
lastPing: int = 0
|
|
sessionIds: list[str] = Field(default_factory=list)
|
|
status: str | None = None
|
|
statusIcon: str | None = None
|
|
statusMessage: str | None = None
|
|
|
|
|
|
# Response wrappers for MCP tools
|
|
|
|
|
|
class ListConversationsResponse(BaseResponse):
|
|
"""Response model for listing Talk conversations."""
|
|
|
|
results: list[TalkConversation] = Field(
|
|
description="Talk conversations the user participates in"
|
|
)
|
|
total: int = Field(description="Number of conversations returned")
|
|
|
|
|
|
class GetConversationResponse(BaseResponse):
|
|
"""Response model for fetching a single Talk conversation."""
|
|
|
|
conversation: TalkConversation = Field(description="The Talk conversation")
|
|
|
|
|
|
class ListMessagesResponse(BaseResponse):
|
|
"""Response model for fetching chat history of a conversation."""
|
|
|
|
conversation_token: str = Field(description="Token of the conversation")
|
|
results: list[TalkMessage] = Field(description="Chat messages in this page")
|
|
count: int = Field(description="Number of messages returned in this page")
|
|
last_known_message_id: int | None = Field(
|
|
default=None,
|
|
description=(
|
|
"ID to pass back as `last_known_message_id` to fetch the next "
|
|
"page (older history). Sourced from the `X-Chat-Last-Given` "
|
|
"response header."
|
|
),
|
|
)
|
|
|
|
|
|
class ListParticipantsResponse(BaseResponse):
|
|
"""Response model for listing participants in a Talk conversation."""
|
|
|
|
conversation_token: str = Field(description="Token of the conversation")
|
|
results: list[TalkParticipant] = Field(
|
|
description="Participants of the conversation"
|
|
)
|
|
count: int = Field(description="Number of participants returned")
|
|
|
|
|
|
class SendMessageResponse(BaseResponse):
|
|
"""Response model returned after posting a chat message."""
|
|
|
|
message: TalkMessage = Field(description="The posted chat message")
|
|
|
|
|
|
class MarkAsReadResponse(StatusResponse):
|
|
"""Response model for the mark-as-read operation."""
|
|
|
|
conversation_token: str = Field(description="Token of the conversation")
|
|
last_read_message: int | None = Field(
|
|
default=None,
|
|
description="The message ID that was marked as the last-read marker",
|
|
)
|