|
1 | 1 | # SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors |
2 | 2 | # SPDX-License-Identifier: AGPL-3.0-or-later |
| 3 | +import json |
| 4 | +from typing import Optional |
3 | 5 | from langchain_core.tools import tool |
4 | 6 | from nc_py_api import AsyncNextcloudApp |
5 | 7 | from nc_py_api.talk import ConversationType |
|
8 | 10 |
|
9 | 11 |
|
10 | 12 | async def get_tools(nc: AsyncNextcloudApp): |
| 13 | + |
| 14 | + async def _get_token(conversation_name: str) -> str: |
| 15 | + conversations = await nc.talk.get_user_conversations() |
| 16 | + conv_map = {conv.display_name: conv for conv in conversations} |
| 17 | + return conv_map[conversation_name].token |
| 18 | + |
| 19 | + # --- Conversations & Messages (enhanced existing tools) --- |
| 20 | + |
11 | 21 | @tool |
12 | 22 | @safe_tool |
13 | 23 | async def list_talk_conversations(): |
14 | 24 | """ |
15 | | - List all conversations of the current user in the Nextcloud Talk app |
16 | | - :return: returns a list of conversation names, e.g. ["Conversation 1", "Conversation 2"] |
| 25 | + List all conversations of the current user in the Nextcloud Talk app. |
| 26 | + Returns conversation names and tokens. The token is needed for other Talk tools. |
| 27 | + :return: list of conversations with name, token, type, and unread message count |
17 | 28 | """ |
18 | 29 | conversations = await nc.talk.get_user_conversations() |
19 | | - |
20 | | - return [conv.display_name for conv in conversations] |
| 30 | + return json.dumps([{ |
| 31 | + 'name': conv.display_name, |
| 32 | + 'token': conv.token, |
| 33 | + 'type': conv.conversation_type, |
| 34 | + 'unread_messages': conv.unread_messages_count, |
| 35 | + } for conv in conversations]) |
21 | 36 |
|
22 | 37 | @tool |
23 | 38 | @dangerous_tool |
24 | 39 | async def create_public_conversation(conversation_name: str) -> str: |
25 | 40 | """ |
26 | | - Create a new conversation in the Nextcloud Talk app |
| 41 | + Create a new public conversation in the Nextcloud Talk app |
27 | 42 | :param conversation_name: The name of the conversation to create |
28 | 43 | :return: The URL of the new conversation |
29 | 44 | """ |
30 | 45 | conversation = await nc.talk.create_conversation(ConversationType.PUBLIC, room_name=conversation_name) |
31 | | - |
32 | 46 | return f"{nc.app_cfg.endpoint}/index.php/call/{conversation.token}" |
33 | 47 |
|
34 | | - |
35 | 48 | @tool |
36 | 49 | @dangerous_tool |
37 | 50 | async def send_message_to_conversation(conversation_name: str, message: str): |
38 | 51 | """ |
39 | | - Send a message to a conversation in the Nextcloud talk app |
| 52 | + Send a message to a conversation in the Nextcloud Talk app |
40 | 53 | :param message: The message to send |
41 | | - :param conversation_name: The name of the conversation to send a message to |
42 | | - :return: |
| 54 | + :param conversation_name: The name of the conversation to send a message to (obtainable via list_talk_conversations) |
| 55 | + :return: success confirmation |
43 | 56 | """ |
44 | 57 | conversations = await nc.talk.get_user_conversations() |
45 | 58 | conversation = {conv.display_name: conv for conv in conversations}[conversation_name] |
46 | 59 | message_with_ai_note = f"{message}\n\nThis message was sent by Nextcloud AI Assistant." |
47 | 60 | await nc.talk.send_message(message_with_ai_note, conversation) |
48 | | - |
49 | | - return True |
| 61 | + return "Message sent successfully." |
50 | 62 |
|
51 | 63 | @tool |
52 | 64 | @safe_tool |
53 | 65 | async def list_messages_in_conversation(conversation_name: str, n_messages: int = 30): |
54 | 66 | """ |
55 | | - List messages of a conversation in the Nextcloud Talk app |
56 | | - :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) |
| 67 | + List messages of a conversation in the Nextcloud Talk app. |
| 68 | + Each message includes its id (needed for reactions and replies) and whether it can be replied to. |
| 69 | + :param conversation_name: The name of the conversation (obtainable via list_talk_conversations) |
57 | 70 | :param n_messages: The number of messages to receive |
58 | | - :return: A list of messages |
| 71 | + :return: list of messages with id, timestamp, actor, message text, and reply status |
59 | 72 | """ |
60 | 73 | conversations = await nc.talk.get_user_conversations() |
61 | 74 | conversation = {conv.display_name: conv for conv in conversations}[conversation_name] |
62 | | - return [f"{m.timestamp} {m.actor_display_name}: {m.message}" for m in await nc.talk.receive_messages(conversation, False, n_messages)] |
| 75 | + messages = await nc.talk.receive_messages(conversation, False, n_messages) |
| 76 | + return json.dumps([{ |
| 77 | + 'id': m.message_id, |
| 78 | + 'timestamp': m.timestamp, |
| 79 | + 'actor': m.actor_display_name, |
| 80 | + 'message': m.message, |
| 81 | + 'is_replyable': m.is_replyable, |
| 82 | + 'reactions': m.reactions, |
| 83 | + } for m in messages]) |
| 84 | + |
| 85 | + # --- Reactions --- |
| 86 | + |
| 87 | + @tool |
| 88 | + @dangerous_tool |
| 89 | + async def add_reaction(conversation_name: str, message_id: int, reaction: str): |
| 90 | + """ |
| 91 | + Add an emoji reaction to a message in a Talk conversation |
| 92 | + :param conversation_name: The name of the conversation (obtainable via list_talk_conversations) |
| 93 | + :param message_id: The id of the message to react to (obtainable via list_messages_in_conversation) |
| 94 | + :param reaction: The reaction emoji (e.g. "\U0001f44d", "❤️", "\U0001f389") |
| 95 | + :return: all reactions on the message grouped by emoji |
| 96 | + """ |
| 97 | + token = await _get_token(conversation_name) |
| 98 | + return json.dumps(await nc.ocs('POST', f'/ocs/v2.php/apps/spreed/api/v1/reaction/{token}/{message_id}', json={ |
| 99 | + 'reaction': reaction, |
| 100 | + })) |
| 101 | + |
| 102 | + @tool |
| 103 | + @dangerous_tool |
| 104 | + async def remove_reaction(conversation_name: str, message_id: int, reaction: str): |
| 105 | + """ |
| 106 | + Remove an emoji reaction from a message in a Talk conversation. |
| 107 | + Only your own reactions can be removed. |
| 108 | + :param conversation_name: The name of the conversation (obtainable via list_talk_conversations) |
| 109 | + :param message_id: The id of the message (obtainable via list_messages_in_conversation) |
| 110 | + :param reaction: The reaction emoji to remove |
| 111 | + :return: remaining reactions on the message grouped by emoji |
| 112 | + """ |
| 113 | + token = await _get_token(conversation_name) |
| 114 | + return json.dumps(await nc.ocs('DELETE', f'/ocs/v2.php/apps/spreed/api/v1/reaction/{token}/{message_id}', json={ |
| 115 | + 'reaction': reaction, |
| 116 | + })) |
| 117 | + |
| 118 | + @tool |
| 119 | + @safe_tool |
| 120 | + async def list_reactions(conversation_name: str, message_id: int, reaction: Optional[str] = None): |
| 121 | + """ |
| 122 | + List all reactions on a message in a Talk conversation |
| 123 | + :param conversation_name: The name of the conversation (obtainable via list_talk_conversations) |
| 124 | + :param message_id: The id of the message (obtainable via list_messages_in_conversation) |
| 125 | + :param reaction: Optional emoji to filter for a specific reaction |
| 126 | + :return: reactions grouped by emoji, each containing a list of actors who reacted |
| 127 | + """ |
| 128 | + token = await _get_token(conversation_name) |
| 129 | + params = {} |
| 130 | + if reaction is not None: |
| 131 | + params['reaction'] = reaction |
| 132 | + return json.dumps(await nc.ocs('GET', f'/ocs/v2.php/apps/spreed/api/v1/reaction/{token}/{message_id}', params=params)) |
| 133 | + |
| 134 | + # --- Reply to message --- |
| 135 | + |
| 136 | + @tool |
| 137 | + @dangerous_tool |
| 138 | + async def reply_to_message(conversation_name: str, message_id: int, message: str, silent: bool = False): |
| 139 | + """ |
| 140 | + Send a message as a reply to another message in a Talk conversation. |
| 141 | + The reply will be visually linked to the original message. |
| 142 | + :param conversation_name: The name of the conversation (obtainable via list_talk_conversations) |
| 143 | + :param message_id: The id of the message to reply to (must have is_replyable=true, obtainable via list_messages_in_conversation) |
| 144 | + :param message: The reply text |
| 145 | + :param silent: If true, no chat notifications will be sent (default false) |
| 146 | + :return: the sent message with its id and parent reference |
| 147 | + """ |
| 148 | + token = await _get_token(conversation_name) |
| 149 | + message_with_ai_note = f"{message}\n\nThis message was sent by Nextcloud AI Assistant." |
| 150 | + return json.dumps(await nc.ocs('POST', f'/ocs/v2.php/apps/spreed/api/v1/chat/{token}', json={ |
| 151 | + 'message': message_with_ai_note, |
| 152 | + 'replyTo': message_id, |
| 153 | + 'silent': silent, |
| 154 | + })) |
| 155 | + |
| 156 | + # --- Polls --- |
| 157 | + |
| 158 | + @tool |
| 159 | + @dangerous_tool |
| 160 | + async def create_poll(conversation_name: str, question: str, options: list[str], result_mode: int = 0, max_votes: int = 0): |
| 161 | + """ |
| 162 | + Create a poll in a Talk conversation |
| 163 | + :param conversation_name: The name of the conversation (obtainable via list_talk_conversations) |
| 164 | + :param question: The poll question |
| 165 | + :param options: List of voting options (e.g. ["Yes", "No", "Maybe"]) |
| 166 | + :param result_mode: 0 = public (results visible immediately), 1 = hidden (results shown only after closing). Default 0. |
| 167 | + :param max_votes: Maximum options a participant can vote for (0 = unlimited). Default 0. |
| 168 | + :return: the created poll with its id, question, options, and status |
| 169 | + """ |
| 170 | + token = await _get_token(conversation_name) |
| 171 | + return json.dumps(await nc.ocs('POST', f'/ocs/v2.php/apps/spreed/api/v1/poll/{token}', json={ |
| 172 | + 'question': question, |
| 173 | + 'options': options, |
| 174 | + 'resultMode': result_mode, |
| 175 | + 'maxVotes': max_votes, |
| 176 | + })) |
| 177 | + |
| 178 | + @tool |
| 179 | + @safe_tool |
| 180 | + async def get_poll(conversation_name: str, poll_id: int): |
| 181 | + """ |
| 182 | + Get the current state and results of a poll |
| 183 | + :param conversation_name: The name of the conversation (obtainable via list_talk_conversations) |
| 184 | + :param poll_id: The id of the poll (obtainable from create_poll or from message parameters in list_messages_in_conversation) |
| 185 | + :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 |
| 186 | + """ |
| 187 | + token = await _get_token(conversation_name) |
| 188 | + return json.dumps(await nc.ocs('GET', f'/ocs/v2.php/apps/spreed/api/v1/poll/{token}/{poll_id}')) |
| 189 | + |
| 190 | + @tool |
| 191 | + @dangerous_tool |
| 192 | + async def vote_on_poll(conversation_name: str, poll_id: int, option_ids: list[int]): |
| 193 | + """ |
| 194 | + Vote on a poll in a Talk conversation. |
| 195 | + Voting replaces any previous votes by the current user. |
| 196 | + :param conversation_name: The name of the conversation (obtainable via list_talk_conversations) |
| 197 | + :param poll_id: The id of the poll |
| 198 | + :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) |
| 199 | + :return: updated poll data with vote counts and own votes |
| 200 | + """ |
| 201 | + token = await _get_token(conversation_name) |
| 202 | + return json.dumps(await nc.ocs('POST', f'/ocs/v2.php/apps/spreed/api/v1/poll/{token}/{poll_id}', json={ |
| 203 | + 'optionIds': option_ids, |
| 204 | + })) |
| 205 | + |
| 206 | + @tool |
| 207 | + @dangerous_tool |
| 208 | + async def close_poll(conversation_name: str, poll_id: int): |
| 209 | + """ |
| 210 | + Close a poll so no more votes can be cast. Only the poll creator or a moderator can close a poll. |
| 211 | + Once closed, full results become visible to all participants. |
| 212 | + :param conversation_name: The name of the conversation (obtainable via list_talk_conversations) |
| 213 | + :param poll_id: The id of the poll to close |
| 214 | + :return: final poll data with complete vote counts and details |
| 215 | + """ |
| 216 | + token = await _get_token(conversation_name) |
| 217 | + return json.dumps(await nc.ocs('DELETE', f'/ocs/v2.php/apps/spreed/api/v1/poll/{token}/{poll_id}')) |
| 218 | + |
| 219 | + # --- File sharing --- |
| 220 | + |
| 221 | + @tool |
| 222 | + @dangerous_tool |
| 223 | + async def share_file_to_conversation(conversation_name: str, file_path: str, caption: Optional[str] = None): |
| 224 | + """ |
| 225 | + Share a file from Nextcloud Files into a Talk conversation. |
| 226 | + The file will appear as a rich message in the chat. |
| 227 | + :param conversation_name: The name of the conversation (obtainable via list_talk_conversations) |
| 228 | + :param file_path: Path to the file in the user's Nextcloud files (e.g. "/Documents/report.pdf") |
| 229 | + :param caption: Optional caption text to display with the shared file |
| 230 | + :return: the created share |
| 231 | + """ |
| 232 | + token = await _get_token(conversation_name) |
| 233 | + payload = { |
| 234 | + 'shareType': 10, |
| 235 | + 'shareWith': token, |
| 236 | + 'path': file_path, |
| 237 | + } |
| 238 | + if caption is not None: |
| 239 | + caption_with_ai_note = f"{caption}\n\nShared by Nextcloud AI Assistant." |
| 240 | + payload['talkMetaData'] = json.dumps({'caption': caption_with_ai_note}) |
| 241 | + return json.dumps(await nc.ocs('POST', '/ocs/v2.php/apps/files_sharing/api/v1/shares', json=payload)) |
| 242 | + |
| 243 | + @tool |
| 244 | + @safe_tool |
| 245 | + async def list_shared_items(conversation_name: str, object_type: str, limit: int = 100): |
| 246 | + """ |
| 247 | + List items of a specific type that have been shared in a Talk conversation. |
| 248 | + For a grouped overview across all types, use list_shared_items_overview instead. |
| 249 | + Note: polls have their own dedicated tools (get_poll, vote_on_poll); for polls the |
| 250 | + dedicated tools are usually more direct than listing them through this one. |
| 251 | + :param conversation_name: The name of the conversation (obtainable via list_talk_conversations) |
| 252 | + :param object_type: The kind of shared item to list. One of "file", "media", |
| 253 | + "audio", "voice", "poll", "location", "deckcard", "recording", "o-talk", "other". |
| 254 | + :param limit: Maximum number of results (default 100, max 200) |
| 255 | + :return: list of chat messages containing shared items of the requested type with their metadata |
| 256 | + """ |
| 257 | + token = await _get_token(conversation_name) |
| 258 | + return json.dumps(await nc.ocs('GET', f'/ocs/v2.php/apps/spreed/api/v1/chat/{token}/share', params={ |
| 259 | + 'objectType': object_type, |
| 260 | + 'limit': limit, |
| 261 | + })) |
| 262 | + |
| 263 | + @tool |
| 264 | + @safe_tool |
| 265 | + async def list_shared_items_overview(conversation_name: str, limit: int = 7): |
| 266 | + """ |
| 267 | + Get an overview of all types of shared items in a Talk conversation (files, media, polls, etc.) |
| 268 | + :param conversation_name: The name of the conversation (obtainable via list_talk_conversations) |
| 269 | + :param limit: Maximum items per category (default 7) |
| 270 | + :return: shared items grouped by type (audio, file, media, poll, etc.) |
| 271 | + """ |
| 272 | + token = await _get_token(conversation_name) |
| 273 | + return json.dumps(await nc.ocs('GET', f'/ocs/v2.php/apps/spreed/api/v1/chat/{token}/share/overview', params={ |
| 274 | + 'limit': limit, |
| 275 | + })) |
63 | 276 |
|
64 | 277 | return [ |
65 | 278 | list_talk_conversations, |
66 | | - list_messages_in_conversation, |
67 | | - send_message_to_conversation, |
68 | 279 | create_public_conversation, |
| 280 | + send_message_to_conversation, |
| 281 | + list_messages_in_conversation, |
| 282 | + add_reaction, |
| 283 | + remove_reaction, |
| 284 | + list_reactions, |
| 285 | + reply_to_message, |
| 286 | + create_poll, |
| 287 | + get_poll, |
| 288 | + vote_on_poll, |
| 289 | + close_poll, |
| 290 | + share_file_to_conversation, |
| 291 | + list_shared_items, |
| 292 | + list_shared_items_overview, |
69 | 293 | ] |
70 | 294 |
|
71 | 295 | def get_category_name(): |
|
0 commit comments