Files
mcp-nextcloud/nextcloud_mcp_server/models/talk.py
T
Chris CoutinhoandClaude Opus 4.7 8f92a7ea29 docs(talk): address PR #741 reviewer nits
Comment-only follow-up to surface non-obvious behavior at the call
sites flagged in review:

- server/talk.py: note the `uuid.uuid4().hex` 32-char no-dashes format
  (spreed accepts either form).
- models/talk.py: warn that spreed returns `lastReadMessage: 0` rather
  than `null` for unread rooms, so consumers should compare to ``None``
  rather than rely on truthiness.
- 10-install-spreed-app.sh: document that the `app:install || app:enable`
  fallback also masks unrelated install failures, and limit its use to
  dev fixtures.

No runtime behavior changes; tests unchanged (still 13 unit + 7 integ).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 00:29:04 +02:00

156 lines
4.8 KiB
Python

"""Pydantic models for the Nextcloud Talk (spreed) integration."""
from typing import Any
from pydantic import BaseModel, 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.
"""
id: int
token: str
type: int
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",
)