|
| 1 | +"""Nextcloud Talk tools — conversations, messages, and participants via OCS API.""" |
| 2 | + |
| 3 | +import json |
| 4 | +from typing import Any |
| 5 | + |
| 6 | +from mcp.server.fastmcp import FastMCP |
| 7 | + |
| 8 | +from ..permissions import PermissionLevel, require_permission |
| 9 | +from ..state import get_client |
| 10 | + |
| 11 | +# Conversation type IDs used by Nextcloud Talk |
| 12 | +_CONVERSATION_TYPES: dict[int, str] = { |
| 13 | + 1: "one-to-one", |
| 14 | + 2: "group", |
| 15 | + 3: "public", |
| 16 | + 4: "changelog", |
| 17 | + 5: "former-one-to-one", |
| 18 | + 6: "note-to-self", |
| 19 | +} |
| 20 | + |
| 21 | +# Room types accepted when creating conversations |
| 22 | +_VALID_ROOM_TYPES = {2: "group", 3: "public"} |
| 23 | + |
| 24 | +# Participant type IDs used by Nextcloud Talk |
| 25 | +_PARTICIPANT_TYPES: dict[int, str] = { |
| 26 | + 1: "owner", |
| 27 | + 2: "moderator", |
| 28 | + 3: "user", |
| 29 | + 4: "guest", |
| 30 | + 5: "user-following-public-link", |
| 31 | + 6: "guest-moderator", |
| 32 | +} |
| 33 | + |
| 34 | + |
| 35 | +def _format_conversation(room: dict[str, Any]) -> dict[str, Any]: |
| 36 | + """Extract the most useful fields from a raw room object.""" |
| 37 | + return { |
| 38 | + "token": room["token"], |
| 39 | + "type": _CONVERSATION_TYPES.get(room.get("type", 0), f"unknown({room.get('type')})"), |
| 40 | + "name": room.get("displayName", room.get("name", "")), |
| 41 | + "description": room.get("description", ""), |
| 42 | + "read_only": room.get("readOnly", 0) == 1, |
| 43 | + "has_call": room.get("hasCall", False), |
| 44 | + "unread_messages": room.get("unreadMessages", 0), |
| 45 | + "unread_mention": room.get("unreadMention", False), |
| 46 | + "last_activity": room.get("lastActivity", 0), |
| 47 | + "is_favorite": room.get("isFavorite", False), |
| 48 | + "participant_count": room.get("participantCount", 0), |
| 49 | + "can_leave": room.get("canLeaveConversation", False), |
| 50 | + "can_delete": room.get("canDeleteConversation", False), |
| 51 | + } |
| 52 | + |
| 53 | + |
| 54 | +def _format_message_compact(msg: dict[str, Any]) -> str: |
| 55 | + """Format a message as a compact single line: [id] author: text.""" |
| 56 | + msg_id = msg.get("id", 0) |
| 57 | + author = msg.get("actorDisplayName", "unknown") |
| 58 | + text = msg.get("message", "") |
| 59 | + return f"[{msg_id}] {author}: {text}" |
| 60 | + |
| 61 | + |
| 62 | +def _format_message_full(msg: dict[str, Any]) -> dict[str, Any]: |
| 63 | + """Extract the most useful fields from a raw message object.""" |
| 64 | + return { |
| 65 | + "id": msg["id"], |
| 66 | + "actor_type": msg.get("actorType", ""), |
| 67 | + "actor_id": msg.get("actorId", ""), |
| 68 | + "actor_display_name": msg.get("actorDisplayName", ""), |
| 69 | + "timestamp": msg.get("timestamp", 0), |
| 70 | + "message": msg.get("message", ""), |
| 71 | + "message_type": msg.get("messageType", ""), |
| 72 | + "system_message": msg.get("systemMessage", ""), |
| 73 | + "is_replyable": msg.get("isReplyable", False), |
| 74 | + } |
| 75 | + |
| 76 | + |
| 77 | +def _format_participant(p: dict[str, Any]) -> dict[str, Any]: |
| 78 | + """Extract the most useful fields from a raw participant object.""" |
| 79 | + return { |
| 80 | + "attendee_id": p.get("attendeeId", 0), |
| 81 | + "actor_type": p.get("actorType", ""), |
| 82 | + "actor_id": p.get("actorId", ""), |
| 83 | + "display_name": p.get("displayName", ""), |
| 84 | + "participant_type": _PARTICIPANT_TYPES.get(p.get("participantType", 0), f"unknown({p.get('participantType')})"), |
| 85 | + "in_call": p.get("inCall", 0) > 0, |
| 86 | + } |
| 87 | + |
| 88 | + |
| 89 | +def _register_read_tools(mcp: FastMCP) -> None: |
| 90 | + """Register read-only Talk tools.""" |
| 91 | + |
| 92 | + @mcp.tool() |
| 93 | + @require_permission(PermissionLevel.READ) |
| 94 | + async def list_conversations(include_notifications_disabled: bool = False) -> str: |
| 95 | + """List all Talk conversations the current user is part of. |
| 96 | +
|
| 97 | + Returns conversations sorted by last activity (newest first). |
| 98 | + Each conversation includes: token (unique ID for API calls), type, |
| 99 | + name, unread counts, and permissions. |
| 100 | +
|
| 101 | + Args: |
| 102 | + include_notifications_disabled: If true, also return conversations where |
| 103 | + notifications are disabled (default: false). |
| 104 | +
|
| 105 | + Returns: |
| 106 | + JSON list of conversation objects. |
| 107 | + """ |
| 108 | + client = get_client() |
| 109 | + params: dict[str, str] = {} |
| 110 | + if not include_notifications_disabled: |
| 111 | + params["noStatusUpdate"] = "0" |
| 112 | + data = await client.ocs_get("apps/spreed/api/v4/room", params=params) |
| 113 | + conversations = [_format_conversation(room) for room in data] |
| 114 | + return json.dumps(conversations, indent=2, default=str) |
| 115 | + |
| 116 | + @mcp.tool() |
| 117 | + @require_permission(PermissionLevel.READ) |
| 118 | + async def get_conversation(token: str) -> str: |
| 119 | + """Get details about a specific Talk conversation. |
| 120 | +
|
| 121 | + Args: |
| 122 | + token: The conversation token (short alphanumeric ID, e.g. "abc12xyz"). |
| 123 | + Use list_conversations to find tokens. |
| 124 | +
|
| 125 | + Returns: |
| 126 | + JSON object with conversation details. |
| 127 | + """ |
| 128 | + client = get_client() |
| 129 | + data = await client.ocs_get(f"apps/spreed/api/v4/room/{token}") |
| 130 | + return json.dumps(_format_conversation(data), indent=2, default=str) |
| 131 | + |
| 132 | + @mcp.tool() |
| 133 | + @require_permission(PermissionLevel.READ) |
| 134 | + async def get_messages( |
| 135 | + token: str, |
| 136 | + limit: int = 50, |
| 137 | + before_message_id: int = 0, |
| 138 | + include_system: bool = False, |
| 139 | + ) -> str: |
| 140 | + """Get chat messages from a Talk conversation. |
| 141 | +
|
| 142 | + Returns messages in reverse chronological order (newest first). |
| 143 | + Uses a compact format: "[id] author: message text" — one line per message. |
| 144 | +
|
| 145 | + IMPORTANT: Start with a small limit (20-50). If you need more context, |
| 146 | + use before_message_id with the oldest message ID from the previous call |
| 147 | + to paginate backwards through the history. |
| 148 | +
|
| 149 | + Args: |
| 150 | + token: The conversation token. Use list_conversations to find tokens. |
| 151 | + limit: Maximum number of messages to return (1-200, default: 50). |
| 152 | + Start small to avoid exceeding response size limits. |
| 153 | + before_message_id: Fetch messages older than this message ID (for pagination). |
| 154 | + Use the smallest message ID from a previous call. |
| 155 | + Default 0 means start from the newest message. |
| 156 | + include_system: Include system messages like "User joined", |
| 157 | + "Conversation created" (default: false — only chat messages). |
| 158 | +
|
| 159 | + Returns: |
| 160 | + Compact text with one message per line: "[id] author: message". |
| 161 | + The last line shows pagination info if more messages may exist. |
| 162 | + """ |
| 163 | + client = get_client() |
| 164 | + limit = max(1, min(200, limit)) |
| 165 | + params: dict[str, str] = { |
| 166 | + "lookIntoFuture": "0", |
| 167 | + "limit": str(limit), |
| 168 | + "setReadMarker": "0", |
| 169 | + } |
| 170 | + if before_message_id: |
| 171 | + params["lastKnownMessageId"] = str(before_message_id) |
| 172 | + data = await client.ocs_get(f"apps/spreed/api/v1/chat/{token}", params=params) |
| 173 | + |
| 174 | + if not include_system: |
| 175 | + data = [m for m in data if not m.get("systemMessage")] |
| 176 | + |
| 177 | + lines = [_format_message_compact(msg) for msg in data] |
| 178 | + |
| 179 | + if data: |
| 180 | + oldest_id = min(m["id"] for m in data) |
| 181 | + lines.append(f"\n--- {len(data)} messages. For older messages, call with before_message_id={oldest_id} ---") |
| 182 | + |
| 183 | + return "\n".join(lines) |
| 184 | + |
| 185 | + @mcp.tool() |
| 186 | + @require_permission(PermissionLevel.READ) |
| 187 | + async def get_participants(token: str) -> str: |
| 188 | + """List participants in a Talk conversation. |
| 189 | +
|
| 190 | + Args: |
| 191 | + token: The conversation token. Use list_conversations to find tokens. |
| 192 | +
|
| 193 | + Returns: |
| 194 | + JSON list of participant objects, each with: attendee_id, |
| 195 | + actor_id, display_name, participant_type (owner/moderator/user/guest), |
| 196 | + in_call status. |
| 197 | + """ |
| 198 | + client = get_client() |
| 199 | + data = await client.ocs_get(f"apps/spreed/api/v4/room/{token}/participants") |
| 200 | + participants = [_format_participant(p) for p in data] |
| 201 | + return json.dumps(participants, indent=2, default=str) |
| 202 | + |
| 203 | + |
| 204 | +def _register_write_tools(mcp: FastMCP) -> None: |
| 205 | + """Register write and destructive Talk tools.""" |
| 206 | + |
| 207 | + @mcp.tool() |
| 208 | + @require_permission(PermissionLevel.WRITE) |
| 209 | + async def send_message(token: str, message: str, reply_to: int = 0) -> str: |
| 210 | + """Send a chat message to a Talk conversation. |
| 211 | +
|
| 212 | + Supports Markdown formatting. Messages can be up to 32000 characters. |
| 213 | + Use @mention syntax to mention users: @"user-id" or @"display name". |
| 214 | +
|
| 215 | + Args: |
| 216 | + token: The conversation token. Use list_conversations to find tokens. |
| 217 | + message: The message text to send (supports Markdown). |
| 218 | + reply_to: Optional message ID to reply to (default: 0 = not a reply). |
| 219 | +
|
| 220 | + Returns: |
| 221 | + JSON object of the sent message with its assigned ID. |
| 222 | + """ |
| 223 | + client = get_client() |
| 224 | + post_data: dict[str, Any] = {"message": message} |
| 225 | + if reply_to: |
| 226 | + post_data["replyTo"] = reply_to |
| 227 | + data = await client.ocs_post(f"apps/spreed/api/v1/chat/{token}", data=post_data) |
| 228 | + return json.dumps(_format_message_full(data), indent=2, default=str) |
| 229 | + |
| 230 | + @mcp.tool() |
| 231 | + @require_permission(PermissionLevel.WRITE) |
| 232 | + async def create_conversation( |
| 233 | + room_type: int, |
| 234 | + name: str, |
| 235 | + invite: str = "", |
| 236 | + ) -> str: |
| 237 | + """Create a new Talk conversation. |
| 238 | +
|
| 239 | + Args: |
| 240 | + room_type: 2 for group conversation, 3 for public conversation. |
| 241 | + Group conversations are invite-only. |
| 242 | + Public conversations can be joined via link. |
| 243 | + name: Display name for the conversation. |
| 244 | + invite: Optional user ID to invite (for group conversations). |
| 245 | +
|
| 246 | + Returns: |
| 247 | + JSON object with the created conversation details, including its token. |
| 248 | + """ |
| 249 | + if room_type not in _VALID_ROOM_TYPES: |
| 250 | + valid = ", ".join(f"{k} ({v})" for k, v in _VALID_ROOM_TYPES.items()) |
| 251 | + raise ValueError(f"Invalid room_type {room_type}. Valid types: {valid}") |
| 252 | + client = get_client() |
| 253 | + post_data: dict[str, Any] = {"roomType": room_type, "roomName": name} |
| 254 | + if invite: |
| 255 | + post_data["invite"] = invite |
| 256 | + data = await client.ocs_post("apps/spreed/api/v4/room", data=post_data) |
| 257 | + return json.dumps(_format_conversation(data), indent=2, default=str) |
| 258 | + |
| 259 | + @mcp.tool() |
| 260 | + @require_permission(PermissionLevel.DESTRUCTIVE) |
| 261 | + async def delete_message(token: str, message_id: int) -> str: |
| 262 | + """Delete a chat message from a Talk conversation. |
| 263 | +
|
| 264 | + Only the message author or a moderator can delete a message. |
| 265 | + The message is replaced with "Message deleted" in the conversation. |
| 266 | +
|
| 267 | + Args: |
| 268 | + token: The conversation token. |
| 269 | + message_id: The ID of the message to delete. |
| 270 | +
|
| 271 | + Returns: |
| 272 | + Confirmation message. |
| 273 | + """ |
| 274 | + client = get_client() |
| 275 | + await client.ocs_delete(f"apps/spreed/api/v1/chat/{token}/{message_id}") |
| 276 | + return f"Message {message_id} deleted from conversation {token}." |
| 277 | + |
| 278 | + @mcp.tool() |
| 279 | + @require_permission(PermissionLevel.DESTRUCTIVE) |
| 280 | + async def leave_conversation(token: str) -> str: |
| 281 | + """Leave a Talk conversation. |
| 282 | +
|
| 283 | + After leaving, the user will no longer receive notifications or see |
| 284 | + the conversation in their list. For group conversations, the user |
| 285 | + can be re-invited. For one-to-one conversations, this removes the |
| 286 | + conversation permanently for the user. |
| 287 | +
|
| 288 | + Args: |
| 289 | + token: The conversation token of the conversation to leave. |
| 290 | +
|
| 291 | + Returns: |
| 292 | + Confirmation message. |
| 293 | + """ |
| 294 | + client = get_client() |
| 295 | + await client.ocs_delete(f"apps/spreed/api/v4/room/{token}/participants/self") |
| 296 | + return f"Left conversation {token}." |
| 297 | + |
| 298 | + |
| 299 | +def register(mcp: FastMCP) -> None: |
| 300 | + """Register Talk tools with the MCP server.""" |
| 301 | + _register_read_tools(mcp) |
| 302 | + _register_write_tools(mcp) |
0 commit comments