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:
co-authored by
Claude Opus 4.7
parent
a9c5759869
commit
69814f30e3
@@ -209,6 +209,7 @@ def require_scopes(*required_scopes: str):
|
||||
"files.",
|
||||
"tables.",
|
||||
"deck.",
|
||||
"talk.",
|
||||
]
|
||||
)
|
||||
]
|
||||
@@ -229,6 +230,7 @@ def require_scopes(*required_scopes: str):
|
||||
"files.",
|
||||
"tables.",
|
||||
"deck.",
|
||||
"talk.",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ from .news import NewsClient
|
||||
from .notes import NotesClient
|
||||
from .sharing import SharingClient
|
||||
from .tables import TablesClient
|
||||
from .talk import TalkClient
|
||||
from .users import UsersClient
|
||||
from .webdav import WebDAVClient
|
||||
from .webhooks import WebhooksClient
|
||||
@@ -85,6 +86,7 @@ class NextcloudClient:
|
||||
self.collectives = CollectivesClient(self._client, username)
|
||||
self.deck = DeckClient(self._client, username)
|
||||
self.news = NewsClient(self._client, username)
|
||||
self.talk = TalkClient(self._client, username)
|
||||
self.users = UsersClient(self._client, username)
|
||||
self.groups = GroupsClient(self._client, username)
|
||||
self.sharing = SharingClient(self._client, username)
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
"""HTTP client for the Nextcloud Talk (spreed) app.
|
||||
|
||||
Talk exposes its REST API under ``/ocs/v2.php/apps/spreed/api/{v}/...``.
|
||||
The current versions used here are:
|
||||
|
||||
- conversations & participants: ``api/v4`` (Nextcloud 22+)
|
||||
- chat: ``api/v1`` (Nextcloud 13+)
|
||||
|
||||
All endpoints follow the OCS envelope ``{"ocs": {"meta": ..., "data": ...}}``,
|
||||
require ``OCS-APIRequest: true`` and respond as JSON when ``Accept:
|
||||
application/json`` is sent.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from nextcloud_mcp_server.client.base import BaseNextcloudClient
|
||||
from nextcloud_mcp_server.models.talk import (
|
||||
TalkConversation,
|
||||
TalkMessage,
|
||||
TalkParticipant,
|
||||
)
|
||||
|
||||
|
||||
class TalkClient(BaseNextcloudClient):
|
||||
"""Client for Nextcloud Talk (spreed) app operations."""
|
||||
|
||||
app_name = "talk"
|
||||
|
||||
_ROOM_BASE = "/ocs/v2.php/apps/spreed/api/v4/room"
|
||||
_CHAT_BASE = "/ocs/v2.php/apps/spreed/api/v1/chat"
|
||||
|
||||
def _talk_headers(self) -> dict[str, str]:
|
||||
"""Standard OCS+JSON headers for spreed API calls."""
|
||||
return {
|
||||
"OCS-APIRequest": "true",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# Conversations (rooms)
|
||||
|
||||
async def list_conversations(
|
||||
self,
|
||||
*,
|
||||
modified_since: int | None = None,
|
||||
include_status: bool = False,
|
||||
no_status_update: bool = True,
|
||||
) -> list[TalkConversation]:
|
||||
"""Return the user's Talk conversations.
|
||||
|
||||
Args:
|
||||
modified_since: If provided, only return conversations modified
|
||||
after this Unix timestamp (server-side filter).
|
||||
include_status: Include user-status info for one-to-one rooms.
|
||||
no_status_update: When True (default), the call does not bump
|
||||
the user's "online" status — appropriate for an MCP server
|
||||
acting in the background.
|
||||
"""
|
||||
params: dict[str, Any] = {}
|
||||
if modified_since is not None:
|
||||
params["modifiedSince"] = modified_since
|
||||
if include_status:
|
||||
params["includeStatus"] = "true"
|
||||
if no_status_update:
|
||||
params["noStatusUpdate"] = 1
|
||||
response = await self._make_request(
|
||||
"GET", self._ROOM_BASE, params=params, headers=self._talk_headers()
|
||||
)
|
||||
data = response.json()["ocs"]["data"]
|
||||
return [TalkConversation(**room) for room in data]
|
||||
|
||||
async def get_conversation(self, token: str) -> TalkConversation:
|
||||
"""Fetch a single Talk conversation by its room token."""
|
||||
response = await self._make_request(
|
||||
"GET", f"{self._ROOM_BASE}/{token}", headers=self._talk_headers()
|
||||
)
|
||||
return TalkConversation(**response.json()["ocs"]["data"])
|
||||
|
||||
async def create_conversation(
|
||||
self,
|
||||
*,
|
||||
room_type: int = 2,
|
||||
room_name: str,
|
||||
invite: str | None = None,
|
||||
) -> TalkConversation:
|
||||
"""Create a new conversation (used for tests/fixtures).
|
||||
|
||||
Args:
|
||||
room_type: 1=one-to-one, 2=group, 3=public. Defaults to 2.
|
||||
room_name: Display name (required for group/public rooms).
|
||||
invite: Optional user/group ID to invite at creation time.
|
||||
|
||||
This client method is not exposed as an MCP tool in the initial
|
||||
Talk integration; it exists so integration tests can spin up
|
||||
scratch rooms.
|
||||
"""
|
||||
body: dict[str, Any] = {"roomType": room_type, "roomName": room_name}
|
||||
if invite is not None:
|
||||
body["invite"] = invite
|
||||
response = await self._make_request(
|
||||
"POST", self._ROOM_BASE, json=body, headers=self._talk_headers()
|
||||
)
|
||||
return TalkConversation(**response.json()["ocs"]["data"])
|
||||
|
||||
async def delete_conversation(self, token: str) -> None:
|
||||
"""Delete a conversation. Used by integration test cleanup."""
|
||||
await self._make_request(
|
||||
"DELETE", f"{self._ROOM_BASE}/{token}", headers=self._talk_headers()
|
||||
)
|
||||
|
||||
# Chat
|
||||
|
||||
async def get_messages(
|
||||
self,
|
||||
token: str,
|
||||
*,
|
||||
limit: int = 50,
|
||||
last_known_message_id: int | None = None,
|
||||
look_into_future: bool = False,
|
||||
set_read_marker: bool = False,
|
||||
include_last_known: bool = False,
|
||||
) -> tuple[list[TalkMessage], int | None]:
|
||||
"""Fetch chat messages for a conversation.
|
||||
|
||||
Args:
|
||||
token: Conversation token.
|
||||
limit: Max messages to return (spreed caps this at 200).
|
||||
last_known_message_id: Pagination cursor — pass the value
|
||||
from the previous response's ``X-Chat-Last-Given`` header.
|
||||
look_into_future: When False (default), return *older*
|
||||
messages relative to ``last_known_message_id`` — i.e.,
|
||||
read history. When True, this becomes a long-poll for
|
||||
new messages, which we don't expose via MCP.
|
||||
set_read_marker: When False (default), the call does not move
|
||||
the user's read marker — consumers can call
|
||||
``mark_as_read`` explicitly.
|
||||
include_last_known: Include the message identified by
|
||||
``last_known_message_id`` itself in the page.
|
||||
|
||||
Returns:
|
||||
``(messages, x_chat_last_given)`` where the integer is the
|
||||
value of the ``X-Chat-Last-Given`` response header (or None
|
||||
if the header was absent), suitable for pagination.
|
||||
"""
|
||||
params: dict[str, Any] = {
|
||||
"limit": limit,
|
||||
"lookIntoFuture": 1 if look_into_future else 0,
|
||||
"setReadMarker": 1 if set_read_marker else 0,
|
||||
"includeLastKnown": 1 if include_last_known else 0,
|
||||
}
|
||||
if last_known_message_id is not None:
|
||||
params["lastKnownMessageId"] = last_known_message_id
|
||||
response = await self._make_request(
|
||||
"GET",
|
||||
f"{self._CHAT_BASE}/{token}",
|
||||
params=params,
|
||||
headers=self._talk_headers(),
|
||||
)
|
||||
# 200 OK → JSON body with messages; 304 Not Modified → no body.
|
||||
# _make_request's raise_for_status() lets 3xx through for GET, but
|
||||
# spreed returns 200 with an empty data list when there's nothing
|
||||
# new, so we trust the JSON body here.
|
||||
last_given_header = response.headers.get("X-Chat-Last-Given")
|
||||
last_given = int(last_given_header) if last_given_header else None
|
||||
data = response.json()["ocs"]["data"]
|
||||
return [TalkMessage(**msg) for msg in data], last_given
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
token: str,
|
||||
message: str,
|
||||
*,
|
||||
reply_to: int | None = None,
|
||||
reference_id: str | None = None,
|
||||
silent: bool = False,
|
||||
) -> TalkMessage:
|
||||
"""Post a chat message to a conversation.
|
||||
|
||||
Args:
|
||||
token: Conversation token.
|
||||
message: Message text (max 32000 chars per spreed docs/chat.md).
|
||||
reply_to: Optional parent message ID to thread this reply.
|
||||
reference_id: Optional client-provided UUID for idempotency on
|
||||
retry (spreed dedupes on this within the conversation).
|
||||
silent: When True, the message is delivered without push
|
||||
notifications.
|
||||
"""
|
||||
body: dict[str, Any] = {"message": message}
|
||||
if reply_to is not None:
|
||||
body["replyTo"] = reply_to
|
||||
if reference_id is not None:
|
||||
body["referenceId"] = reference_id
|
||||
if silent:
|
||||
body["silent"] = True
|
||||
response = await self._make_request(
|
||||
"POST",
|
||||
f"{self._CHAT_BASE}/{token}",
|
||||
json=body,
|
||||
headers=self._talk_headers(),
|
||||
)
|
||||
return TalkMessage(**response.json()["ocs"]["data"])
|
||||
|
||||
async def mark_as_read(
|
||||
self, token: str, *, last_read_message: int | None = None
|
||||
) -> None:
|
||||
"""Mark the conversation as read.
|
||||
|
||||
If ``last_read_message`` is provided it sets the read marker to
|
||||
that message; otherwise spreed marks everything currently in the
|
||||
room as read.
|
||||
"""
|
||||
body: dict[str, Any] = {}
|
||||
if last_read_message is not None:
|
||||
body["lastReadMessage"] = last_read_message
|
||||
await self._make_request(
|
||||
"POST",
|
||||
f"{self._CHAT_BASE}/{token}/read",
|
||||
json=body,
|
||||
headers=self._talk_headers(),
|
||||
)
|
||||
|
||||
# Participants
|
||||
|
||||
async def list_participants(
|
||||
self, token: str, *, include_status: bool = False
|
||||
) -> list[TalkParticipant]:
|
||||
"""List participants of a Talk conversation."""
|
||||
params: dict[str, Any] = {}
|
||||
if include_status:
|
||||
params["includeStatus"] = "true"
|
||||
response = await self._make_request(
|
||||
"GET",
|
||||
f"{self._ROOM_BASE}/{token}/participants",
|
||||
params=params,
|
||||
headers=self._talk_headers(),
|
||||
)
|
||||
data = response.json()["ocs"]["data"]
|
||||
return [TalkParticipant(**p) for p in data]
|
||||
@@ -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",
|
||||
)
|
||||
@@ -12,6 +12,7 @@ from .notes import configure_notes_tools
|
||||
from .semantic import configure_semantic_tools
|
||||
from .sharing import configure_sharing_tools
|
||||
from .tables import configure_tables_tools
|
||||
from .talk import configure_talk_tools
|
||||
from .webdav import configure_webdav_tools
|
||||
|
||||
# Canonical mapping of app name → tool registration function.
|
||||
@@ -29,6 +30,7 @@ AVAILABLE_APPS: dict[str, Callable[[FastMCP], None]] = {
|
||||
"cookbook": configure_cookbook_tools,
|
||||
"deck": configure_deck_tools,
|
||||
"news": configure_news_tools,
|
||||
"talk": configure_talk_tools,
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
@@ -43,5 +45,6 @@ __all__ = [
|
||||
"configure_semantic_tools",
|
||||
"configure_sharing_tools",
|
||||
"configure_tables_tools",
|
||||
"configure_talk_tools",
|
||||
"configure_webdav_tools",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"""MCP tool registration for the Nextcloud Talk (spreed) integration."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.types import ToolAnnotations
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
from nextcloud_mcp_server.models.talk import (
|
||||
GetConversationResponse,
|
||||
ListConversationsResponse,
|
||||
ListMessagesResponse,
|
||||
ListParticipantsResponse,
|
||||
MarkAsReadResponse,
|
||||
SendMessageResponse,
|
||||
)
|
||||
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# spreed advertises a 32000-character limit on chat messages (docs/chat.md);
|
||||
# we enforce it client-side for a clearer error than the server's 413.
|
||||
_MESSAGE_MAX_LENGTH = 32000
|
||||
|
||||
|
||||
def _validate_message_text(message: str) -> None:
|
||||
if not message:
|
||||
raise ValueError("Message text must not be empty")
|
||||
if len(message) > _MESSAGE_MAX_LENGTH:
|
||||
raise ValueError(
|
||||
f"Message too long: {len(message)} characters (max {_MESSAGE_MAX_LENGTH})"
|
||||
)
|
||||
|
||||
|
||||
def configure_talk_tools(mcp: FastMCP) -> None:
|
||||
"""Configure Nextcloud Talk (spreed) MCP tools."""
|
||||
|
||||
# Read tools
|
||||
|
||||
@mcp.tool(
|
||||
title="List Talk Conversations",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("talk.read")
|
||||
@instrument_tool
|
||||
async def talk_list_conversations(
|
||||
ctx: Context,
|
||||
modified_since: int | None = None,
|
||||
include_status: bool = False,
|
||||
) -> ListConversationsResponse:
|
||||
"""List the user's Talk conversations (rooms).
|
||||
|
||||
Args:
|
||||
modified_since: Optional Unix timestamp; only conversations
|
||||
modified after this time are returned.
|
||||
include_status: Whether to include user-status info for
|
||||
one-to-one conversations.
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
rooms = await client.talk.list_conversations(
|
||||
modified_since=modified_since,
|
||||
include_status=include_status,
|
||||
)
|
||||
return ListConversationsResponse(results=rooms, total=len(rooms))
|
||||
|
||||
@mcp.tool(
|
||||
title="Get Talk Conversation",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("talk.read")
|
||||
@instrument_tool
|
||||
async def talk_get_conversation(
|
||||
ctx: Context, token: str
|
||||
) -> GetConversationResponse:
|
||||
"""Get details of a Talk conversation by its token.
|
||||
|
||||
Args:
|
||||
token: Unique room token (returned by ``talk_list_conversations``).
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
conversation = await client.talk.get_conversation(token)
|
||||
return GetConversationResponse(conversation=conversation)
|
||||
|
||||
@mcp.tool(
|
||||
title="Get Talk Messages",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("talk.read")
|
||||
@instrument_tool
|
||||
async def talk_get_messages(
|
||||
ctx: Context,
|
||||
token: str,
|
||||
limit: int = 50,
|
||||
last_known_message_id: int | None = None,
|
||||
include_last_known: bool = False,
|
||||
) -> ListMessagesResponse:
|
||||
"""Read chat history for a Talk conversation.
|
||||
|
||||
Returns the most recent messages (older first when paginated).
|
||||
Does not move the user's read marker; call
|
||||
``talk_mark_as_read`` separately if desired.
|
||||
|
||||
Args:
|
||||
token: Conversation token.
|
||||
limit: Max messages per page (spreed caps at 200).
|
||||
last_known_message_id: Pagination cursor — pass the
|
||||
``last_known_message_id`` from the previous response to
|
||||
fetch the next (older) page.
|
||||
include_last_known: Include the cursor message in the page
|
||||
instead of starting just before it.
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
messages, last_given = await client.talk.get_messages(
|
||||
token,
|
||||
limit=limit,
|
||||
last_known_message_id=last_known_message_id,
|
||||
look_into_future=False,
|
||||
set_read_marker=False,
|
||||
include_last_known=include_last_known,
|
||||
)
|
||||
return ListMessagesResponse(
|
||||
conversation_token=token,
|
||||
results=messages,
|
||||
count=len(messages),
|
||||
last_known_message_id=last_given,
|
||||
)
|
||||
|
||||
@mcp.tool(
|
||||
title="List Talk Conversation Participants",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("talk.read")
|
||||
@instrument_tool
|
||||
async def talk_list_participants(
|
||||
ctx: Context, token: str, include_status: bool = False
|
||||
) -> ListParticipantsResponse:
|
||||
"""List the participants of a Talk conversation.
|
||||
|
||||
Args:
|
||||
token: Conversation token.
|
||||
include_status: Include each participant's user-status info.
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
participants = await client.talk.list_participants(
|
||||
token, include_status=include_status
|
||||
)
|
||||
return ListParticipantsResponse(
|
||||
conversation_token=token,
|
||||
results=participants,
|
||||
count=len(participants),
|
||||
)
|
||||
|
||||
# Write tools
|
||||
|
||||
@mcp.tool(
|
||||
title="Send Talk Message",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("talk.write")
|
||||
@instrument_tool
|
||||
async def talk_send_message(
|
||||
ctx: Context,
|
||||
token: str,
|
||||
message: str,
|
||||
reply_to: int | None = None,
|
||||
silent: bool = False,
|
||||
) -> SendMessageResponse:
|
||||
"""Post a chat message into a Talk conversation as the user.
|
||||
|
||||
A random ``referenceId`` is attached so spreed dedupes the post
|
||||
if the request is retried.
|
||||
|
||||
Args:
|
||||
token: Conversation token.
|
||||
message: Message text (max 32000 characters).
|
||||
reply_to: Optional parent message ID to thread the reply.
|
||||
silent: When True the message is delivered without push
|
||||
notifications (e.g. for status updates).
|
||||
"""
|
||||
_validate_message_text(message)
|
||||
client = await get_client(ctx)
|
||||
posted = await client.talk.send_message(
|
||||
token,
|
||||
message,
|
||||
reply_to=reply_to,
|
||||
reference_id=uuid.uuid4().hex,
|
||||
silent=silent,
|
||||
)
|
||||
return SendMessageResponse(message=posted)
|
||||
|
||||
@mcp.tool(
|
||||
title="Mark Talk Conversation as Read",
|
||||
annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("talk.write")
|
||||
@instrument_tool
|
||||
async def talk_mark_as_read(
|
||||
ctx: Context,
|
||||
token: str,
|
||||
last_read_message: int | None = None,
|
||||
) -> MarkAsReadResponse:
|
||||
"""Move the user's read marker forward in a Talk conversation.
|
||||
|
||||
Args:
|
||||
token: Conversation token.
|
||||
last_read_message: Optional message ID to mark as the new
|
||||
read position. When omitted, spreed marks everything
|
||||
currently in the room as read.
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
await client.talk.mark_as_read(token, last_read_message=last_read_message)
|
||||
return MarkAsReadResponse(
|
||||
success=True,
|
||||
message="Conversation marked as read",
|
||||
conversation_token=token,
|
||||
last_read_message=last_read_message,
|
||||
)
|
||||
Reference in New Issue
Block a user