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
@@ -358,6 +358,85 @@ def create_mock_deck_comment_response(
|
||||
return create_mock_response(status_code=200, json_data=ocs_response)
|
||||
|
||||
|
||||
def create_mock_talk_room_response(
|
||||
conversation_id: int = 1,
|
||||
token: str = "abc123",
|
||||
name: str = "Test Conversation",
|
||||
room_type: int = 2,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock OCS response for a Nextcloud Talk conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: Numeric conversation ID
|
||||
token: Unique room token
|
||||
name: Conversation name
|
||||
room_type: 1=one-to-one, 2=group, 3=public, 4=changelog
|
||||
**kwargs: Additional conversation fields (override defaults)
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with conversation data wrapped in OCS format.
|
||||
"""
|
||||
room_data = {
|
||||
"id": conversation_id,
|
||||
"token": token,
|
||||
"type": room_type,
|
||||
"name": name,
|
||||
"displayName": name,
|
||||
"description": "",
|
||||
"participantType": 1,
|
||||
"unreadMessages": 0,
|
||||
"unreadMention": False,
|
||||
"lastActivity": 1700000000,
|
||||
"lastReadMessage": 0,
|
||||
"lastMessage": [],
|
||||
"readOnly": 0,
|
||||
"isFavorite": False,
|
||||
"notificationLevel": 0,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
ocs_response = {"ocs": {"meta": {"status": "ok"}, "data": room_data}}
|
||||
return create_mock_response(status_code=200, json_data=ocs_response)
|
||||
|
||||
|
||||
def create_mock_talk_message_response(
|
||||
message_id: int = 1,
|
||||
token: str = "abc123",
|
||||
text: str = "Hello, world!",
|
||||
actor_id: str = "testuser",
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock OCS response for a Nextcloud Talk chat message.
|
||||
|
||||
Args:
|
||||
message_id: Numeric message ID
|
||||
token: Conversation token the message belongs to
|
||||
text: Message body
|
||||
actor_id: User ID of the message author
|
||||
**kwargs: Additional message fields (override defaults)
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with message data wrapped in OCS format.
|
||||
"""
|
||||
message_data = {
|
||||
"id": message_id,
|
||||
"token": token,
|
||||
"actorType": "users",
|
||||
"actorId": actor_id,
|
||||
"actorDisplayName": "Test User",
|
||||
"timestamp": 1700000000,
|
||||
"systemMessage": "",
|
||||
"messageType": "comment",
|
||||
"message": text,
|
||||
"messageParameters": {},
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
ocs_response = {"ocs": {"meta": {"status": "ok"}, "data": message_data}}
|
||||
return create_mock_response(status_code=200, json_data=ocs_response)
|
||||
|
||||
|
||||
def create_mock_tables_list_response(
|
||||
tables: list[dict] = None,
|
||||
) -> httpx.Response:
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
"""Unit tests for the Nextcloud Talk (spreed) HTTP client."""
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.client.talk import TalkClient
|
||||
from nextcloud_mcp_server.models.talk import (
|
||||
TalkConversation,
|
||||
TalkMessage,
|
||||
TalkParticipant,
|
||||
)
|
||||
from tests.client.conftest import (
|
||||
create_mock_error_response,
|
||||
create_mock_response,
|
||||
create_mock_talk_message_response,
|
||||
create_mock_talk_room_response,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
# Conversation tests
|
||||
|
||||
|
||||
async def test_talk_list_conversations(mocker):
|
||||
"""list_conversations parses the OCS-wrapped room list."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data={
|
||||
"ocs": {
|
||||
"meta": {"status": "ok"},
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"token": "abc",
|
||||
"type": 2,
|
||||
"name": "Group 1",
|
||||
"displayName": "Group 1",
|
||||
"lastMessage": [],
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"token": "def",
|
||||
"type": 3,
|
||||
"name": "Public Room",
|
||||
"displayName": "Public Room",
|
||||
"lastMessage": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
mock_make_request = mocker.patch.object(
|
||||
TalkClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
|
||||
rooms = await client.list_conversations()
|
||||
|
||||
assert len(rooms) == 2
|
||||
assert all(isinstance(r, TalkConversation) for r in rooms)
|
||||
assert rooms[0].token == "abc"
|
||||
assert rooms[1].type == 3
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
call_args = mock_make_request.call_args
|
||||
assert call_args[0][0] == "GET"
|
||||
assert call_args[0][1] == "/ocs/v2.php/apps/spreed/api/v4/room"
|
||||
# noStatusUpdate defaults to True
|
||||
assert call_args[1]["params"]["noStatusUpdate"] == 1
|
||||
|
||||
|
||||
async def test_talk_list_conversations_with_modified_since(mocker):
|
||||
"""modifiedSince and includeStatus are forwarded as query params."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data={"ocs": {"meta": {"status": "ok"}, "data": []}},
|
||||
)
|
||||
mock_make_request = mocker.patch.object(
|
||||
TalkClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
|
||||
await client.list_conversations(modified_since=1700000000, include_status=True)
|
||||
|
||||
params = mock_make_request.call_args[1]["params"]
|
||||
assert params["modifiedSince"] == 1700000000
|
||||
assert params["includeStatus"] == "true"
|
||||
|
||||
|
||||
async def test_talk_get_conversation(mocker):
|
||||
"""get_conversation parses the OCS-wrapped room object."""
|
||||
mock_response = create_mock_talk_room_response(
|
||||
conversation_id=42, token="xyz", name="My Room"
|
||||
)
|
||||
mock_make_request = mocker.patch.object(
|
||||
TalkClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
|
||||
room = await client.get_conversation("xyz")
|
||||
|
||||
assert isinstance(room, TalkConversation)
|
||||
assert room.id == 42
|
||||
assert room.token == "xyz"
|
||||
assert room.name == "My Room"
|
||||
|
||||
assert mock_make_request.call_args[0][0] == "GET"
|
||||
assert "/api/v4/room/xyz" in mock_make_request.call_args[0][1]
|
||||
|
||||
|
||||
async def test_talk_get_conversation_not_found(mocker):
|
||||
"""A 404 from spreed propagates as HTTPStatusError."""
|
||||
error_response = create_mock_error_response(404, "Conversation not found")
|
||||
mock_make_request = mocker.patch.object(TalkClient, "_make_request")
|
||||
mock_make_request.side_effect = httpx.HTTPStatusError(
|
||||
"404 Not Found",
|
||||
request=httpx.Request("GET", "http://test.local"),
|
||||
response=error_response,
|
||||
)
|
||||
|
||||
client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
|
||||
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||
await client.get_conversation("nope")
|
||||
assert excinfo.value.response.status_code == 404
|
||||
|
||||
|
||||
async def test_talk_create_conversation(mocker):
|
||||
"""create_conversation builds the right POST body."""
|
||||
mock_response = create_mock_talk_room_response(
|
||||
conversation_id=10, token="new", name="New Room"
|
||||
)
|
||||
mock_make_request = mocker.patch.object(
|
||||
TalkClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
|
||||
room = await client.create_conversation(room_name="New Room")
|
||||
|
||||
assert room.token == "new"
|
||||
call_args = mock_make_request.call_args
|
||||
assert call_args[0][0] == "POST"
|
||||
assert call_args[1]["json"] == {"roomType": 2, "roomName": "New Room"}
|
||||
|
||||
|
||||
async def test_talk_delete_conversation(mocker):
|
||||
"""delete_conversation issues DELETE on the room URL."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data={"ocs": {"meta": {"status": "ok"}, "data": []}},
|
||||
)
|
||||
mock_make_request = mocker.patch.object(
|
||||
TalkClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
|
||||
await client.delete_conversation("doomed")
|
||||
|
||||
call_args = mock_make_request.call_args
|
||||
assert call_args[0][0] == "DELETE"
|
||||
assert "/api/v4/room/doomed" in call_args[0][1]
|
||||
|
||||
|
||||
# Chat tests
|
||||
|
||||
|
||||
async def test_talk_get_messages(mocker):
|
||||
"""get_messages parses the OCS-wrapped message list."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
headers={"X-Chat-Last-Given": "100"},
|
||||
json_data={
|
||||
"ocs": {
|
||||
"meta": {"status": "ok"},
|
||||
"data": [
|
||||
{
|
||||
"id": 101,
|
||||
"token": "abc",
|
||||
"actorType": "users",
|
||||
"actorId": "alice",
|
||||
"actorDisplayName": "Alice",
|
||||
"timestamp": 1700000000,
|
||||
"messageType": "comment",
|
||||
"message": "Hi",
|
||||
},
|
||||
{
|
||||
"id": 102,
|
||||
"token": "abc",
|
||||
"actorType": "users",
|
||||
"actorId": "bob",
|
||||
"actorDisplayName": "Bob",
|
||||
"timestamp": 1700000010,
|
||||
"messageType": "comment",
|
||||
"message": "Hello",
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
mock_make_request = mocker.patch.object(
|
||||
TalkClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
|
||||
messages, last_given = await client.get_messages("abc", limit=20)
|
||||
|
||||
assert len(messages) == 2
|
||||
assert all(isinstance(m, TalkMessage) for m in messages)
|
||||
assert messages[0].id == 101
|
||||
assert messages[1].actorId == "bob"
|
||||
assert last_given == 100
|
||||
|
||||
call_args = mock_make_request.call_args
|
||||
assert call_args[0][0] == "GET"
|
||||
assert "/chat/abc" in call_args[0][1]
|
||||
params = call_args[1]["params"]
|
||||
assert params["limit"] == 20
|
||||
# Defaults: not look_into_future, not set_read_marker
|
||||
assert params["lookIntoFuture"] == 0
|
||||
assert params["setReadMarker"] == 0
|
||||
|
||||
|
||||
async def test_talk_get_messages_pagination_cursor(mocker):
|
||||
"""last_known_message_id is forwarded as the spreed cursor param."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data={"ocs": {"meta": {"status": "ok"}, "data": []}},
|
||||
)
|
||||
mock_make_request = mocker.patch.object(
|
||||
TalkClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
|
||||
messages, last_given = await client.get_messages(
|
||||
"abc", last_known_message_id=500, include_last_known=True
|
||||
)
|
||||
|
||||
assert messages == []
|
||||
assert last_given is None # No header on this response
|
||||
|
||||
params = mock_make_request.call_args[1]["params"]
|
||||
assert params["lastKnownMessageId"] == 500
|
||||
assert params["includeLastKnown"] == 1
|
||||
|
||||
|
||||
async def test_talk_send_message(mocker):
|
||||
"""send_message posts the message text and parses the response."""
|
||||
mock_response = create_mock_talk_message_response(
|
||||
message_id=999, token="abc", text="Posted from MCP"
|
||||
)
|
||||
mock_make_request = mocker.patch.object(
|
||||
TalkClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
|
||||
posted = await client.send_message("abc", "Posted from MCP")
|
||||
|
||||
assert isinstance(posted, TalkMessage)
|
||||
assert posted.id == 999
|
||||
assert posted.message == "Posted from MCP"
|
||||
|
||||
call_args = mock_make_request.call_args
|
||||
assert call_args[0][0] == "POST"
|
||||
assert "/chat/abc" in call_args[0][1]
|
||||
body = call_args[1]["json"]
|
||||
assert body["message"] == "Posted from MCP"
|
||||
# Optional fields are not sent unless explicitly set
|
||||
assert "replyTo" not in body
|
||||
assert "referenceId" not in body
|
||||
assert "silent" not in body
|
||||
|
||||
|
||||
async def test_talk_send_message_with_reference_id_and_reply(mocker):
|
||||
"""send_message forwards reply_to, reference_id, silent."""
|
||||
mock_response = create_mock_talk_message_response(
|
||||
message_id=1000, token="abc", text="A reply"
|
||||
)
|
||||
mock_make_request = mocker.patch.object(
|
||||
TalkClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
|
||||
await client.send_message(
|
||||
"abc",
|
||||
"A reply",
|
||||
reply_to=999,
|
||||
reference_id="dedupe-token",
|
||||
silent=True,
|
||||
)
|
||||
|
||||
body = mock_make_request.call_args[1]["json"]
|
||||
assert body == {
|
||||
"message": "A reply",
|
||||
"replyTo": 999,
|
||||
"referenceId": "dedupe-token",
|
||||
"silent": True,
|
||||
}
|
||||
|
||||
|
||||
async def test_talk_mark_as_read_no_message(mocker):
|
||||
"""mark_as_read with no message ID sends an empty body."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data={"ocs": {"meta": {"status": "ok"}, "data": []}},
|
||||
)
|
||||
mock_make_request = mocker.patch.object(
|
||||
TalkClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
|
||||
await client.mark_as_read("abc")
|
||||
|
||||
call_args = mock_make_request.call_args
|
||||
assert call_args[0][0] == "POST"
|
||||
assert "/chat/abc/read" in call_args[0][1]
|
||||
assert call_args[1]["json"] == {}
|
||||
|
||||
|
||||
async def test_talk_mark_as_read_with_message(mocker):
|
||||
"""mark_as_read sends lastReadMessage when provided."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data={"ocs": {"meta": {"status": "ok"}, "data": []}},
|
||||
)
|
||||
mock_make_request = mocker.patch.object(
|
||||
TalkClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
|
||||
await client.mark_as_read("abc", last_read_message=500)
|
||||
|
||||
assert mock_make_request.call_args[1]["json"] == {"lastReadMessage": 500}
|
||||
|
||||
|
||||
# Participant tests
|
||||
|
||||
|
||||
async def test_talk_list_participants(mocker):
|
||||
"""list_participants parses the OCS-wrapped participant list."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data={
|
||||
"ocs": {
|
||||
"meta": {"status": "ok"},
|
||||
"data": [
|
||||
{
|
||||
"attendeeId": 1,
|
||||
"actorType": "users",
|
||||
"actorId": "alice",
|
||||
"displayName": "Alice",
|
||||
"participantType": 1,
|
||||
"inCall": 0,
|
||||
"lastPing": 1700000000,
|
||||
},
|
||||
{
|
||||
"attendeeId": 2,
|
||||
"actorType": "users",
|
||||
"actorId": "bob",
|
||||
"displayName": "Bob",
|
||||
"participantType": 3,
|
||||
"inCall": 0,
|
||||
"lastPing": 1700000000,
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
mock_make_request = mocker.patch.object(
|
||||
TalkClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = TalkClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
|
||||
participants = await client.list_participants("abc")
|
||||
|
||||
assert len(participants) == 2
|
||||
assert all(isinstance(p, TalkParticipant) for p in participants)
|
||||
assert participants[0].actorId == "alice"
|
||||
assert participants[1].participantType == 3
|
||||
|
||||
call_args = mock_make_request.call_args
|
||||
assert call_args[0][0] == "GET"
|
||||
assert "/api/v4/room/abc/participants" in call_args[0][1]
|
||||
Reference in New Issue
Block a user