From 25893789aa5f9f1d9812690bedee2d643049c578 Mon Sep 17 00:00:00 2001 From: Pavlinchen Date: Wed, 22 Apr 2026 06:47:06 +0200 Subject: [PATCH 1/3] feat(talk): add reactions, replies, polls, and file sharing tools Extend the Talk tool module from 4 to 15 MCP tools, adding four major feature areas: Reactions (3 tools): - add_reaction: react to a message with an emoji - remove_reaction: remove own reaction from a message - list_reactions: list all reactions on a message Reply-to-message (1 tool): - reply_to_message: send a message as a reply to another message, visually linking them in the chat Polls (4 tools): - create_poll: create a poll with options, result mode, vote limits - get_poll: get current poll state and results - vote_on_poll: cast votes on poll options - close_poll: close a poll to finalize results File sharing (3 tools): - share_file_to_conversation: share a Nextcloud file into a chat (via core Share API with shareType=10) - list_shared_files: list files shared in a conversation - list_shared_items_overview: overview of all shared item types Also enhances two existing tools: - list_talk_conversations: now returns structured data including conversation tokens (needed by other tools), type, and unread count - list_messages_in_conversation: now returns structured data including message IDs (needed for reactions/replies), replyable status, and existing reactions New tools use nc.ocs() for OCS endpoints, consistent with shares.py. A shared _get_token() helper resolves conversation names to tokens, matching the existing name-based lookup pattern. Tested against Nextcloud 32.0.8 with Talk (spreed) 22.0.11. Signed-off-by: Pavlinchen --- ex_app/lib/all_tools/talk.py | 253 ++++++++++++++++++++++++++++++++--- 1 file changed, 236 insertions(+), 17 deletions(-) diff --git a/ex_app/lib/all_tools/talk.py b/ex_app/lib/all_tools/talk.py index d5ff67a..49ddb84 100644 --- a/ex_app/lib/all_tools/talk.py +++ b/ex_app/lib/all_tools/talk.py @@ -1,5 +1,7 @@ # SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: AGPL-3.0-or-later +import json +from typing import Optional from langchain_core.tools import tool from nc_py_api import AsyncNextcloudApp from nc_py_api.talk import ConversationType @@ -8,64 +10,281 @@ async def get_tools(nc: AsyncNextcloudApp): + + async def _get_token(conversation_name: str) -> str: + conversations = await nc.talk.get_user_conversations() + conv_map = {conv.display_name: conv for conv in conversations} + return conv_map[conversation_name].token + + # --- Conversations & Messages (enhanced existing tools) --- + @tool @safe_tool async def list_talk_conversations(): """ - List all conversations of the current user in the Nextcloud Talk app - :return: returns a list of conversation names, e.g. ["Conversation 1", "Conversation 2"] + List all conversations of the current user in the Nextcloud Talk app. + Returns conversation names and tokens. The token is needed for other Talk tools. + :return: list of conversations with name, token, type, and unread message count """ conversations = await nc.talk.get_user_conversations() - - return [conv.display_name for conv in conversations] + return json.dumps([{ + 'name': conv.display_name, + 'token': conv.token, + 'type': conv.conversation_type, + 'unread_messages': conv.unread_messages_count, + } for conv in conversations]) @tool @dangerous_tool async def create_public_conversation(conversation_name: str) -> str: """ - Create a new conversation in the Nextcloud Talk app + Create a new public conversation in the Nextcloud Talk app :param conversation_name: The name of the conversation to create :return: The URL of the new conversation """ conversation = await nc.talk.create_conversation(ConversationType.PUBLIC, room_name=conversation_name) - return f"{nc.app_cfg.endpoint}/index.php/call/{conversation.token}" - @tool @dangerous_tool async def send_message_to_conversation(conversation_name: str, message: str): """ - Send a message to a conversation in the Nextcloud talk app + Send a message to a conversation in the Nextcloud Talk app :param message: The message to send - :param conversation_name: The name of the conversation to send a message to - :return: + :param conversation_name: The name of the conversation to send a message to (obtainable via list_talk_conversations) + :return: success confirmation """ conversations = await nc.talk.get_user_conversations() conversation = {conv.display_name: conv for conv in conversations}[conversation_name] message_with_ai_note = f"{message}\n\nThis message was sent by Nextcloud AI Assistant." await nc.talk.send_message(message_with_ai_note, conversation) - return True @tool @safe_tool async def list_messages_in_conversation(conversation_name: str, n_messages: int = 30): """ - List messages of a conversation in the Nextcloud Talk app - :param conversation_name: The name of the conversation to list messages of (can only be one conversation per Tool call, obtainable via list_talk_conversations) + List messages of a conversation in the Nextcloud Talk app. + Each message includes its id (needed for reactions and replies) and whether it can be replied to. + :param conversation_name: The name of the conversation (obtainable via list_talk_conversations) :param n_messages: The number of messages to receive - :return: A list of messages + :return: list of messages with id, timestamp, actor, message text, and reply status """ conversations = await nc.talk.get_user_conversations() conversation = {conv.display_name: conv for conv in conversations}[conversation_name] - return [f"{m.timestamp} {m.actor_display_name}: {m.message}" for m in await nc.talk.receive_messages(conversation, False, n_messages)] + messages = await nc.talk.receive_messages(conversation, False, n_messages) + return json.dumps([{ + 'id': m.message_id, + 'timestamp': m.timestamp, + 'actor': m.actor_display_name, + 'message': m.message, + 'is_replyable': m.is_replyable, + 'reactions': m.reactions, + } for m in messages]) + + # --- Reactions --- + + @tool + @dangerous_tool + async def add_reaction(conversation_name: str, message_id: int, reaction: str): + """ + Add an emoji reaction to a message in a Talk conversation + :param conversation_name: The name of the conversation (obtainable via list_talk_conversations) + :param message_id: The id of the message to react to (obtainable via list_messages_in_conversation) + :param reaction: The reaction emoji (e.g. "\U0001f44d", "❤️", "\U0001f389") + :return: all reactions on the message grouped by emoji + """ + token = await _get_token(conversation_name) + return await nc.ocs('POST', f'/ocs/v2.php/apps/spreed/api/v1/reaction/{token}/{message_id}', json={ + 'reaction': reaction, + }) + + @tool + @dangerous_tool + async def remove_reaction(conversation_name: str, message_id: int, reaction: str): + """ + Remove an emoji reaction from a message in a Talk conversation. + Only your own reactions can be removed. + :param conversation_name: The name of the conversation (obtainable via list_talk_conversations) + :param message_id: The id of the message (obtainable via list_messages_in_conversation) + :param reaction: The reaction emoji to remove + :return: remaining reactions on the message grouped by emoji + """ + token = await _get_token(conversation_name) + return await nc.ocs('DELETE', f'/ocs/v2.php/apps/spreed/api/v1/reaction/{token}/{message_id}', json={ + 'reaction': reaction, + }) + + @tool + @safe_tool + async def list_reactions(conversation_name: str, message_id: int, reaction: Optional[str] = None): + """ + List all reactions on a message in a Talk conversation + :param conversation_name: The name of the conversation (obtainable via list_talk_conversations) + :param message_id: The id of the message (obtainable via list_messages_in_conversation) + :param reaction: Optional emoji to filter for a specific reaction + :return: reactions grouped by emoji, each containing a list of actors who reacted + """ + token = await _get_token(conversation_name) + params = {} + if reaction is not None: + params['reaction'] = reaction + return await nc.ocs('GET', f'/ocs/v2.php/apps/spreed/api/v1/reaction/{token}/{message_id}', params=params) + + # --- Reply to message --- + + @tool + @dangerous_tool + async def reply_to_message(conversation_name: str, message_id: int, message: str, silent: bool = False): + """ + Send a message as a reply to another message in a Talk conversation. + The reply will be visually linked to the original message. + :param conversation_name: The name of the conversation (obtainable via list_talk_conversations) + :param message_id: The id of the message to reply to (must have is_replyable=true, obtainable via list_messages_in_conversation) + :param message: The reply text + :param silent: If true, no chat notifications will be sent (default false) + :return: the sent message with its id and parent reference + """ + token = await _get_token(conversation_name) + message_with_ai_note = f"{message}\n\nThis message was sent by Nextcloud AI Assistant." + return await nc.ocs('POST', f'/ocs/v2.php/apps/spreed/api/v1/chat/{token}', json={ + 'message': message_with_ai_note, + 'replyTo': message_id, + 'silent': silent, + }) + + # --- Polls --- + + @tool + @dangerous_tool + async def create_poll(conversation_name: str, question: str, options: list[str], result_mode: int = 0, max_votes: int = 0): + """ + Create a poll in a Talk conversation + :param conversation_name: The name of the conversation (obtainable via list_talk_conversations) + :param question: The poll question + :param options: List of voting options (e.g. ["Yes", "No", "Maybe"]) + :param result_mode: 0 = public (results visible immediately), 1 = hidden (results shown only after closing). Default 0. + :param max_votes: Maximum options a participant can vote for (0 = unlimited). Default 0. + :return: the created poll with its id, question, options, and status + """ + token = await _get_token(conversation_name) + return await nc.ocs('POST', f'/ocs/v2.php/apps/spreed/api/v1/poll/{token}', json={ + 'question': question, + 'options': options, + 'resultMode': result_mode, + 'maxVotes': max_votes, + }) + + @tool + @safe_tool + async def get_poll(conversation_name: str, poll_id: int): + """ + Get the current state and results of a poll + :param conversation_name: The name of the conversation (obtainable via list_talk_conversations) + :param poll_id: The id of the poll (obtainable from create_poll or from message parameters in list_messages_in_conversation) + :return: poll data including question, options, votes, status, and who voted + """ + token = await _get_token(conversation_name) + return await nc.ocs('GET', f'/ocs/v2.php/apps/spreed/api/v1/poll/{token}/{poll_id}') + + @tool + @dangerous_tool + async def vote_on_poll(conversation_name: str, poll_id: int, option_ids: list[int]): + """ + Vote on a poll in a Talk conversation. + Voting replaces any previous votes by the current user. + :param conversation_name: The name of the conversation (obtainable via list_talk_conversations) + :param poll_id: The id of the poll + :param option_ids: List of option indices to vote for (0-based, e.g. [0] to vote for the first option, [0, 2] to vote for first and third) + :return: updated poll data with vote counts and own votes + """ + token = await _get_token(conversation_name) + return await nc.ocs('POST', f'/ocs/v2.php/apps/spreed/api/v1/poll/{token}/{poll_id}', json={ + 'optionIds': option_ids, + }) + + @tool + @dangerous_tool + async def close_poll(conversation_name: str, poll_id: int): + """ + Close a poll so no more votes can be cast. Only the poll creator or a moderator can close a poll. + Once closed, full results become visible to all participants. + :param conversation_name: The name of the conversation (obtainable via list_talk_conversations) + :param poll_id: The id of the poll to close + :return: final poll data with complete vote counts and details + """ + token = await _get_token(conversation_name) + return await nc.ocs('DELETE', f'/ocs/v2.php/apps/spreed/api/v1/poll/{token}/{poll_id}') + + # --- File sharing --- + + @tool + @dangerous_tool + async def share_file_to_conversation(conversation_name: str, file_path: str, caption: Optional[str] = None): + """ + Share a file from Nextcloud Files into a Talk conversation. + The file will appear as a rich message in the chat. + :param conversation_name: The name of the conversation (obtainable via list_talk_conversations) + :param file_path: Path to the file in the user's Nextcloud files (e.g. "/Documents/report.pdf") + :param caption: Optional caption text to display with the shared file + :return: the created share + """ + token = await _get_token(conversation_name) + payload = { + 'shareType': 10, + 'shareWith': token, + 'path': file_path, + } + if caption is not None: + caption_with_ai_note = f"{caption}\n\nShared by Nextcloud AI Assistant." + payload['talkMetaData'] = json.dumps({'caption': caption_with_ai_note}) + return await nc.ocs('POST', '/ocs/v2.php/apps/files_sharing/api/v1/shares', json=payload) + + @tool + @safe_tool + async def list_shared_files(conversation_name: str, limit: int = 100): + """ + List files that have been shared in a Talk conversation + :param conversation_name: The name of the conversation (obtainable via list_talk_conversations) + :param limit: Maximum number of results (default 100, max 200) + :return: list of chat messages containing shared files with file metadata (name, size, mimetype, link) + """ + token = await _get_token(conversation_name) + return await nc.ocs('GET', f'/ocs/v2.php/apps/spreed/api/v1/chat/{token}/share', params={ + 'objectType': 'file', + 'limit': limit, + }) + + @tool + @safe_tool + async def list_shared_items_overview(conversation_name: str, limit: int = 7): + """ + Get an overview of all types of shared items in a Talk conversation (files, media, polls, etc.) + :param conversation_name: The name of the conversation (obtainable via list_talk_conversations) + :param limit: Maximum items per category (default 7) + :return: shared items grouped by type (audio, file, media, poll, etc.) + """ + token = await _get_token(conversation_name) + return await nc.ocs('GET', f'/ocs/v2.php/apps/spreed/api/v1/chat/{token}/share/overview', params={ + 'limit': limit, + }) return [ list_talk_conversations, - list_messages_in_conversation, - send_message_to_conversation, create_public_conversation, + send_message_to_conversation, + list_messages_in_conversation, + add_reaction, + remove_reaction, + list_reactions, + reply_to_message, + create_poll, + get_poll, + vote_on_poll, + close_poll, + share_file_to_conversation, + list_shared_files, + list_shared_items_overview, ] def get_category_name(): From 5b0aa9c7a6f1eb6927fafd775bb890a1defbbae4 Mon Sep 17 00:00:00 2001 From: Pavlinchen Date: Wed, 22 Apr 2026 22:04:03 +0200 Subject: [PATCH 2/3] docs(talk): clarify 0-based option indexing in get_poll return Per review feedback on #157: the LLM reading get_poll's result was off-by-one when subsequently calling vote_on_poll, because nothing in get_poll's :return: description signaled that the options array is 0-indexed. Mirror the phrasing already used by vote_on_poll's option_ids param so the index semantics are established the moment the LLM reads poll data, not only when it later inspects the vote_on_poll signature. Signed-off-by: Pavlinchen --- ex_app/lib/all_tools/talk.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ex_app/lib/all_tools/talk.py b/ex_app/lib/all_tools/talk.py index 49ddb84..cc017db 100644 --- a/ex_app/lib/all_tools/talk.py +++ b/ex_app/lib/all_tools/talk.py @@ -182,7 +182,7 @@ async def get_poll(conversation_name: str, poll_id: int): Get the current state and results of a poll :param conversation_name: The name of the conversation (obtainable via list_talk_conversations) :param poll_id: The id of the poll (obtainable from create_poll or from message parameters in list_messages_in_conversation) - :return: poll data including question, options, votes, status, and who voted + :return: poll data including question, options (0-based, e.g. [0] refers to the first option, [2] refers to the third option, etc.), votes, status, and who voted """ token = await _get_token(conversation_name) return await nc.ocs('GET', f'/ocs/v2.php/apps/spreed/api/v1/poll/{token}/{poll_id}') From a7db9de7c2f73ae01c9e657f86bfe53842ccc229 Mon Sep 17 00:00:00 2001 From: Pavlinchen Date: Fri, 22 May 2026 08:16:34 +0200 Subject: [PATCH 3/3] feat(talk): return strings from all tools; parameterize list_shared_items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Return strings from all Talk tools for OpenAI API compatibility: - send_message_to_conversation: True → "Message sent successfully." - add_reaction, remove_reaction, list_reactions: wrap nc.ocs() with json.dumps() - reply_to_message: wrap nc.ocs() with json.dumps() - create_poll, get_poll, vote_on_poll, close_poll: wrap nc.ocs() with json.dumps() - share_file_to_conversation, list_shared_items, list_shared_items_overview: wrap nc.ocs() with json.dumps() Some OpenAI API implementations error on non-string tool outputs, per maintainer review feedback on #155. Applying the same fix proactively here avoids a second review round-trip. Also parameterize list_shared_items (renamed from list_shared_files): the objectType was hardcoded to "file"; it now accepts any of the ten valid types (file, media, audio, voice, poll, location, deckcard, recording, o-talk, other), making the tool useful for the full range of shared content in a conversation. Signed-off-by: Pavlinchen --- ex_app/lib/all_tools/talk.py | 53 ++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/ex_app/lib/all_tools/talk.py b/ex_app/lib/all_tools/talk.py index cc017db..82098a9 100644 --- a/ex_app/lib/all_tools/talk.py +++ b/ex_app/lib/all_tools/talk.py @@ -58,7 +58,7 @@ async def send_message_to_conversation(conversation_name: str, message: str): conversation = {conv.display_name: conv for conv in conversations}[conversation_name] message_with_ai_note = f"{message}\n\nThis message was sent by Nextcloud AI Assistant." await nc.talk.send_message(message_with_ai_note, conversation) - return True + return "Message sent successfully." @tool @safe_tool @@ -95,9 +95,9 @@ async def add_reaction(conversation_name: str, message_id: int, reaction: str): :return: all reactions on the message grouped by emoji """ token = await _get_token(conversation_name) - return await nc.ocs('POST', f'/ocs/v2.php/apps/spreed/api/v1/reaction/{token}/{message_id}', json={ + return json.dumps(await nc.ocs('POST', f'/ocs/v2.php/apps/spreed/api/v1/reaction/{token}/{message_id}', json={ 'reaction': reaction, - }) + })) @tool @dangerous_tool @@ -111,9 +111,9 @@ async def remove_reaction(conversation_name: str, message_id: int, reaction: str :return: remaining reactions on the message grouped by emoji """ token = await _get_token(conversation_name) - return await nc.ocs('DELETE', f'/ocs/v2.php/apps/spreed/api/v1/reaction/{token}/{message_id}', json={ + return json.dumps(await nc.ocs('DELETE', f'/ocs/v2.php/apps/spreed/api/v1/reaction/{token}/{message_id}', json={ 'reaction': reaction, - }) + })) @tool @safe_tool @@ -129,7 +129,7 @@ async def list_reactions(conversation_name: str, message_id: int, reaction: Opti params = {} if reaction is not None: params['reaction'] = reaction - return await nc.ocs('GET', f'/ocs/v2.php/apps/spreed/api/v1/reaction/{token}/{message_id}', params=params) + return json.dumps(await nc.ocs('GET', f'/ocs/v2.php/apps/spreed/api/v1/reaction/{token}/{message_id}', params=params)) # --- Reply to message --- @@ -147,11 +147,11 @@ async def reply_to_message(conversation_name: str, message_id: int, message: str """ token = await _get_token(conversation_name) message_with_ai_note = f"{message}\n\nThis message was sent by Nextcloud AI Assistant." - return await nc.ocs('POST', f'/ocs/v2.php/apps/spreed/api/v1/chat/{token}', json={ + return json.dumps(await nc.ocs('POST', f'/ocs/v2.php/apps/spreed/api/v1/chat/{token}', json={ 'message': message_with_ai_note, 'replyTo': message_id, 'silent': silent, - }) + })) # --- Polls --- @@ -168,12 +168,12 @@ async def create_poll(conversation_name: str, question: str, options: list[str], :return: the created poll with its id, question, options, and status """ token = await _get_token(conversation_name) - return await nc.ocs('POST', f'/ocs/v2.php/apps/spreed/api/v1/poll/{token}', json={ + return json.dumps(await nc.ocs('POST', f'/ocs/v2.php/apps/spreed/api/v1/poll/{token}', json={ 'question': question, 'options': options, 'resultMode': result_mode, 'maxVotes': max_votes, - }) + })) @tool @safe_tool @@ -185,7 +185,7 @@ async def get_poll(conversation_name: str, poll_id: int): :return: poll data including question, options (0-based, e.g. [0] refers to the first option, [2] refers to the third option, etc.), votes, status, and who voted """ token = await _get_token(conversation_name) - return await nc.ocs('GET', f'/ocs/v2.php/apps/spreed/api/v1/poll/{token}/{poll_id}') + return json.dumps(await nc.ocs('GET', f'/ocs/v2.php/apps/spreed/api/v1/poll/{token}/{poll_id}')) @tool @dangerous_tool @@ -199,9 +199,9 @@ async def vote_on_poll(conversation_name: str, poll_id: int, option_ids: list[in :return: updated poll data with vote counts and own votes """ token = await _get_token(conversation_name) - return await nc.ocs('POST', f'/ocs/v2.php/apps/spreed/api/v1/poll/{token}/{poll_id}', json={ + return json.dumps(await nc.ocs('POST', f'/ocs/v2.php/apps/spreed/api/v1/poll/{token}/{poll_id}', json={ 'optionIds': option_ids, - }) + })) @tool @dangerous_tool @@ -214,7 +214,7 @@ async def close_poll(conversation_name: str, poll_id: int): :return: final poll data with complete vote counts and details """ token = await _get_token(conversation_name) - return await nc.ocs('DELETE', f'/ocs/v2.php/apps/spreed/api/v1/poll/{token}/{poll_id}') + return json.dumps(await nc.ocs('DELETE', f'/ocs/v2.php/apps/spreed/api/v1/poll/{token}/{poll_id}')) # --- File sharing --- @@ -238,22 +238,27 @@ async def share_file_to_conversation(conversation_name: str, file_path: str, cap if caption is not None: caption_with_ai_note = f"{caption}\n\nShared by Nextcloud AI Assistant." payload['talkMetaData'] = json.dumps({'caption': caption_with_ai_note}) - return await nc.ocs('POST', '/ocs/v2.php/apps/files_sharing/api/v1/shares', json=payload) + return json.dumps(await nc.ocs('POST', '/ocs/v2.php/apps/files_sharing/api/v1/shares', json=payload)) @tool @safe_tool - async def list_shared_files(conversation_name: str, limit: int = 100): + async def list_shared_items(conversation_name: str, object_type: str, limit: int = 100): """ - List files that have been shared in a Talk conversation + List items of a specific type that have been shared in a Talk conversation. + For a grouped overview across all types, use list_shared_items_overview instead. + Note: polls have their own dedicated tools (get_poll, vote_on_poll); for polls the + dedicated tools are usually more direct than listing them through this one. :param conversation_name: The name of the conversation (obtainable via list_talk_conversations) + :param object_type: The kind of shared item to list. One of "file", "media", + "audio", "voice", "poll", "location", "deckcard", "recording", "o-talk", "other". :param limit: Maximum number of results (default 100, max 200) - :return: list of chat messages containing shared files with file metadata (name, size, mimetype, link) + :return: list of chat messages containing shared items of the requested type with their metadata """ token = await _get_token(conversation_name) - return await nc.ocs('GET', f'/ocs/v2.php/apps/spreed/api/v1/chat/{token}/share', params={ - 'objectType': 'file', + return json.dumps(await nc.ocs('GET', f'/ocs/v2.php/apps/spreed/api/v1/chat/{token}/share', params={ + 'objectType': object_type, 'limit': limit, - }) + })) @tool @safe_tool @@ -265,9 +270,9 @@ async def list_shared_items_overview(conversation_name: str, limit: int = 7): :return: shared items grouped by type (audio, file, media, poll, etc.) """ token = await _get_token(conversation_name) - return await nc.ocs('GET', f'/ocs/v2.php/apps/spreed/api/v1/chat/{token}/share/overview', params={ + return json.dumps(await nc.ocs('GET', f'/ocs/v2.php/apps/spreed/api/v1/chat/{token}/share/overview', params={ 'limit': limit, - }) + })) return [ list_talk_conversations, @@ -283,7 +288,7 @@ async def list_shared_items_overview(conversation_name: str, limit: int = 7): vote_on_poll, close_poll, share_file_to_conversation, - list_shared_files, + list_shared_items, list_shared_items_overview, ]