Skip to content

Commit 4e96747

Browse files
authored
Merge pull request #4 from cloud-py-api/feature/p2-talk-polls
Add Talk poll tools: get_poll, create_poll, vote_poll, close_poll
2 parents f392aa7 + 2c66ff1 commit 4e96747

8 files changed

Lines changed: 601 additions & 64 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ jobs:
6161
strategy:
6262
fail-fast: false
6363
matrix:
64-
nextcloud-version: ["31", "32"]
64+
nextcloud-version: ["32", "33"]
6565
services:
6666
nextcloud:
6767
image: nextcloud:${{ matrix.nextcloud-version }}

GUIDANCE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Rules and corrections for writing code in this project.
88
- Never import inside functions. All imports must be at the top of the file.
99
- Never use `# noqa: F401` — if something is imported, it must be used.
1010
- Maximum line length is 120 characters.
11+
- No decorative/separator comments (e.g. `# -----------` section banners). Code structure should be self-evident. Only add comments that explain *why*, never *what*.
1112

1213
## Tooling
1314

PROGRESS.md

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
- [x] Notifications tools: list_notifications, dismiss_notification, dismiss_all_notifications (2026-03-21)
1515
- [x] Test suite overhaul: MCP tool-level integration tests (2026-03-21)
1616
- [x] Talk tools: list_conversations, get_conversation, get_messages, get_participants, send_message, create_conversation, delete_message, leave_conversation (2026-03-22)
17+
- [x] Talk polls: get_poll, create_poll, vote_poll, close_poll (2026-03-24)
1718

1819
### In Progress
1920
- [ ] Activity tools: get_activity
@@ -23,7 +24,6 @@
2324
(none)
2425

2526
### Next Up
26-
- Talk polls: get_poll, create_poll, vote_poll
2727
- Announcement Center
2828
- Files Sharing, Trashbin, Versions
2929
- Improve error handling and error messages
@@ -39,13 +39,15 @@
3939

4040
## Test Coverage
4141

42-
| Module | Tools | Integration Tests |
43-
|--------|-------|-------------------|
44-
| Files | 6 | 20 |
45-
| Users | 3 | 10 |
46-
| Notifications | 3 | 12 |
47-
| Talk | 8 | 44 |
42+
| Module | Tools | Tests |
43+
|--------|-------|-------|
44+
| Files | 6 | 24 |
45+
| Users | 3 | 13 |
46+
| Notifications | 3 | 11 |
47+
| Talk | 8 | 48 |
48+
| Talk Polls | 4 | 31 |
4849
| Server || 5 |
49-
| Permissions || 16 |
50+
| Permissions || 34 |
5051
| Errors || 10 |
51-
| **Total** | **20** | **127** |
52+
| Config || 12 |
53+
| **Total** | **24** | **188** |

src/nextcloud_mcp/client.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,12 +106,14 @@ async def ocs_post(self, path: str, data: dict[str, Any] | None = None) -> Any:
106106
result: dict[str, Any] = response.json() # type: ignore[assignment]
107107
return result["ocs"]["data"]
108108

109-
async def ocs_delete(self, path: str) -> None:
110-
"""Make an OCS DELETE request."""
109+
async def ocs_delete(self, path: str) -> Any:
110+
"""Make an OCS DELETE request and return the data portion (if any)."""
111111
session = await self._get_session()
112112
url = f"{self._base_url}/ocs/v2.php/{path}"
113113
response = await session.delete(url)
114114
_raise_for_status(response, f"OCS DELETE {path}")
115+
result: dict[str, Any] = response.json() # type: ignore[assignment]
116+
return result["ocs"]["data"]
115117

116118
# --- WebDAV ---
117119

src/nextcloud_mcp/tools/talk.py

Lines changed: 157 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Nextcloud Talk tools — conversations, messages, and participants via OCS API."""
1+
"""Nextcloud Talk tools — conversations, messages, participants, and polls via OCS API."""
22

33
import json
44
from typing import Any
@@ -32,6 +32,42 @@
3232
}
3333

3434

35+
# Poll status codes
36+
_POLL_STATUS: dict[int, str] = {
37+
0: "open",
38+
1: "closed",
39+
2: "draft",
40+
}
41+
42+
_RESULT_MODES: dict[int, str] = {
43+
0: "public",
44+
1: "hidden",
45+
}
46+
47+
48+
def _format_poll(poll: dict[str, Any]) -> dict[str, Any]:
49+
"""Extract the most useful fields from a raw poll object."""
50+
result: dict[str, Any] = {
51+
"id": poll["id"],
52+
"question": poll["question"],
53+
"options": poll.get("options", []),
54+
"status": _POLL_STATUS.get(poll.get("status", 0), f"unknown({poll.get('status')})"),
55+
"result_mode": _RESULT_MODES.get(poll.get("resultMode", 0), f"unknown({poll.get('resultMode')})"),
56+
"max_votes": poll.get("maxVotes", 0),
57+
"actor_id": poll.get("actorId", ""),
58+
"actor_display_name": poll.get("actorDisplayName", ""),
59+
"num_voters": poll.get("numVoters", 0),
60+
"voted_self": poll.get("votedSelf", []),
61+
}
62+
votes = poll.get("votes")
63+
if votes:
64+
result["votes"] = votes
65+
details = poll.get("details")
66+
if details:
67+
result["details"] = details
68+
return result
69+
70+
3571
def _format_conversation(room: dict[str, Any]) -> dict[str, Any]:
3672
"""Extract the most useful fields from a raw room object."""
3773
return {
@@ -200,9 +236,127 @@ async def get_participants(token: str) -> str:
200236
participants = [_format_participant(p) for p in data]
201237
return json.dumps(participants, indent=2, default=str)
202238

239+
@mcp.tool()
240+
@require_permission(PermissionLevel.READ)
241+
async def get_poll(token: str, poll_id: int) -> str:
242+
"""Get a poll from a Talk conversation.
243+
244+
Returns poll details including question, options, current votes (if visible),
245+
and which options the current user voted for.
246+
247+
Vote visibility depends on the poll's result_mode:
248+
- "public": votes are visible after you vote.
249+
- "hidden": votes are only visible after the poll is closed.
250+
251+
Args:
252+
token: The conversation token. Use list_conversations to find tokens.
253+
poll_id: The poll ID. Poll IDs appear in chat messages when a poll is created.
254+
255+
Returns:
256+
JSON object with poll details: id, question, options, status,
257+
result_mode, max_votes, votes, num_voters, voted_self.
258+
"""
259+
client = get_client()
260+
data = await client.ocs_get(f"apps/spreed/api/v1/poll/{token}/{poll_id}")
261+
return json.dumps(_format_poll(data), indent=2, default=str)
262+
263+
264+
def _register_poll_tools(mcp: FastMCP) -> None:
265+
"""Register poll-related Talk tools (write + destructive)."""
266+
267+
@mcp.tool()
268+
@require_permission(PermissionLevel.WRITE)
269+
async def create_poll(
270+
token: str,
271+
question: str,
272+
options: list[str],
273+
result_mode: int = 0,
274+
max_votes: int = 0,
275+
) -> str:
276+
"""Create a poll in a Talk conversation.
277+
278+
Polls can only be created in group or public conversations (not one-to-one).
279+
A chat message is automatically posted announcing the poll.
280+
281+
Args:
282+
token: The conversation token. Use list_conversations to find tokens.
283+
question: The poll question (max 32,000 characters).
284+
options: List of voting options (minimum 2 options required).
285+
Example: ["Yes", "No", "Maybe"]
286+
result_mode: 0 for public results (voters see results immediately after voting),
287+
1 for hidden results (results shown only after poll is closed).
288+
Default: 0 (public).
289+
max_votes: Maximum number of options a user can vote for.
290+
0 means unlimited (user can select all options). Default: 0.
291+
292+
Returns:
293+
JSON object with poll details: id, question, options, status, result_mode, max_votes.
294+
"""
295+
if len(options) < 2:
296+
raise ValueError("A poll requires at least 2 options.")
297+
client = get_client()
298+
post_data: dict[str, Any] = {
299+
"question": question,
300+
"options[]": options,
301+
"resultMode": result_mode,
302+
"maxVotes": max_votes,
303+
}
304+
data = await client.ocs_post(f"apps/spreed/api/v1/poll/{token}", data=post_data)
305+
return json.dumps(_format_poll(data), indent=2, default=str)
306+
307+
@mcp.tool()
308+
@require_permission(PermissionLevel.WRITE)
309+
async def vote_poll(token: str, poll_id: int, option_ids: list[int]) -> str:
310+
"""Vote on a poll in a Talk conversation.
311+
312+
Voting replaces any previous vote — calling this again with different
313+
option_ids changes your vote. You cannot vote on closed polls.
314+
315+
Args:
316+
token: The conversation token.
317+
poll_id: The poll ID. Use get_poll to see available polls.
318+
option_ids: List of option indices to vote for (0-based).
319+
For example, if options are ["Yes", "No", "Maybe"],
320+
use [0] to vote "Yes", or [0, 2] to vote "Yes" and "Maybe".
321+
The number of choices must not exceed the poll's max_votes
322+
(0 means unlimited).
323+
324+
Returns:
325+
JSON object with updated poll details including your votes (voted_self)
326+
and current vote counts (if visible).
327+
"""
328+
if not option_ids:
329+
raise ValueError("You must vote for at least one option.")
330+
client = get_client()
331+
post_data: dict[str, Any] = {"optionIds[]": option_ids}
332+
data = await client.ocs_post(f"apps/spreed/api/v1/poll/{token}/{poll_id}", data=post_data)
333+
return json.dumps(_format_poll(data), indent=2, default=str)
334+
335+
@mcp.tool()
336+
@require_permission(PermissionLevel.DESTRUCTIVE)
337+
async def close_poll(token: str, poll_id: int) -> str:
338+
"""Close a poll in a Talk conversation.
339+
340+
Once closed, no more votes can be cast and results become visible
341+
to all participants (regardless of result_mode). Only the poll
342+
creator or a conversation moderator can close a poll.
343+
344+
This action is irreversible — a closed poll cannot be reopened.
345+
346+
Args:
347+
token: The conversation token.
348+
poll_id: The poll ID to close.
349+
350+
Returns:
351+
JSON object with the final poll results including all votes and details.
352+
"""
353+
client = get_client()
354+
data = await client.ocs_delete(f"apps/spreed/api/v1/poll/{token}/{poll_id}")
355+
return json.dumps(_format_poll(data), indent=2, default=str)
356+
203357

204358
def _register_write_tools(mcp: FastMCP) -> None:
205-
"""Register write and destructive Talk tools."""
359+
"""Register write and destructive Talk tools for conversations and messages."""
206360

207361
@mcp.tool()
208362
@require_permission(PermissionLevel.WRITE)
@@ -299,4 +453,5 @@ async def leave_conversation(token: str) -> str:
299453
def register(mcp: FastMCP) -> None:
300454
"""Register Talk tools with the MCP server."""
301455
_register_read_tools(mcp)
456+
_register_poll_tools(mcp)
302457
_register_write_tools(mcp)

tests/integration/test_server.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
pytestmark = pytest.mark.integration
1010

1111
EXPECTED_TOOLS = [
12+
"close_poll",
1213
"create_conversation",
1314
"create_directory",
15+
"create_poll",
1416
"delete_file",
1517
"delete_message",
1618
"dismiss_all_notifications",
@@ -20,6 +22,7 @@
2022
"get_file",
2123
"get_messages",
2224
"get_participants",
25+
"get_poll",
2326
"get_user",
2427
"leave_conversation",
2528
"list_conversations",
@@ -29,6 +32,7 @@
2932
"move_file",
3033
"send_message",
3134
"upload_file",
35+
"vote_poll",
3236
]
3337

3438

0 commit comments

Comments
 (0)