feat(talk): add MCP integration for Nextcloud Talk (spreed)

Adds 6 MCP tools so an LLM can read a user's Talk conversations and
post messages on their behalf, addressing the "read my chats and reply"
use case from issue #720:

  - talk_list_conversations
  - talk_get_conversation
  - talk_get_messages
  - talk_list_participants
  - talk_send_message    (auto-attaches a referenceId for retry dedup)
  - talk_mark_as_read

Edit/delete messages, reactions, threads, and call/session ops are
intentionally out of scope for this first PR.

The TalkClient also exposes create_conversation/delete_conversation
for the integration test fixture; these are not registered as MCP
tools. A post-installation hook enables spreed in the docker dev env
so the integration suite has a real Talk backend to talk to.

Closes #720

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-04-29 23:19:57 +02:00
co-authored by Claude Opus 4.7
parent a9c5759869
commit 69814f30e3
12 changed files with 1278 additions and 3 deletions
+152
View File
@@ -0,0 +1,152 @@
"""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 | None = ""
participantType: int | None = None
unreadMessages: int = 0
unreadMention: bool = False
lastActivity: int | None = None
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",
)