|
1 | | -"""Nextcloud Talk tools — conversations, messages, and participants via OCS API.""" |
| 1 | +"""Nextcloud Talk tools — conversations, messages, participants, and polls via OCS API.""" |
2 | 2 |
|
3 | 3 | import json |
4 | 4 | from typing import Any |
|
32 | 32 | } |
33 | 33 |
|
34 | 34 |
|
| 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 | + |
35 | 71 | def _format_conversation(room: dict[str, Any]) -> dict[str, Any]: |
36 | 72 | """Extract the most useful fields from a raw room object.""" |
37 | 73 | return { |
@@ -200,9 +236,127 @@ async def get_participants(token: str) -> str: |
200 | 236 | participants = [_format_participant(p) for p in data] |
201 | 237 | return json.dumps(participants, indent=2, default=str) |
202 | 238 |
|
| 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 | + |
203 | 357 |
|
204 | 358 | 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.""" |
206 | 360 |
|
207 | 361 | @mcp.tool() |
208 | 362 | @require_permission(PermissionLevel.WRITE) |
@@ -299,4 +453,5 @@ async def leave_conversation(token: str) -> str: |
299 | 453 | def register(mcp: FastMCP) -> None: |
300 | 454 | """Register Talk tools with the MCP server.""" |
301 | 455 | _register_read_tools(mcp) |
| 456 | + _register_poll_tools(mcp) |
302 | 457 | _register_write_tools(mcp) |
0 commit comments