mindroom 0.0.0__py3-none-any.whl → 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mindroom/__init__.py +3 -0
- mindroom/agent_prompts.py +963 -0
- mindroom/agents.py +248 -0
- mindroom/ai.py +421 -0
- mindroom/api/__init__.py +1 -0
- mindroom/api/credentials.py +137 -0
- mindroom/api/google_integration.py +355 -0
- mindroom/api/google_tools_helper.py +40 -0
- mindroom/api/homeassistant_integration.py +421 -0
- mindroom/api/integrations.py +189 -0
- mindroom/api/main.py +506 -0
- mindroom/api/matrix_operations.py +219 -0
- mindroom/api/tools.py +94 -0
- mindroom/background_tasks.py +87 -0
- mindroom/bot.py +2470 -0
- mindroom/cli.py +86 -0
- mindroom/commands.py +377 -0
- mindroom/config.py +343 -0
- mindroom/config_commands.py +324 -0
- mindroom/config_confirmation.py +411 -0
- mindroom/constants.py +52 -0
- mindroom/credentials.py +146 -0
- mindroom/credentials_sync.py +134 -0
- mindroom/custom_tools/__init__.py +8 -0
- mindroom/custom_tools/config_manager.py +765 -0
- mindroom/custom_tools/gmail.py +92 -0
- mindroom/custom_tools/google_calendar.py +92 -0
- mindroom/custom_tools/google_sheets.py +92 -0
- mindroom/custom_tools/homeassistant.py +341 -0
- mindroom/error_handling.py +35 -0
- mindroom/file_watcher.py +49 -0
- mindroom/interactive.py +313 -0
- mindroom/logging_config.py +207 -0
- mindroom/matrix/__init__.py +1 -0
- mindroom/matrix/client.py +782 -0
- mindroom/matrix/event_info.py +173 -0
- mindroom/matrix/identity.py +149 -0
- mindroom/matrix/large_messages.py +267 -0
- mindroom/matrix/mentions.py +141 -0
- mindroom/matrix/message_builder.py +94 -0
- mindroom/matrix/message_content.py +209 -0
- mindroom/matrix/presence.py +178 -0
- mindroom/matrix/rooms.py +311 -0
- mindroom/matrix/state.py +77 -0
- mindroom/matrix/typing.py +91 -0
- mindroom/matrix/users.py +217 -0
- mindroom/memory/__init__.py +21 -0
- mindroom/memory/config.py +137 -0
- mindroom/memory/functions.py +396 -0
- mindroom/py.typed +0 -0
- mindroom/response_tracker.py +128 -0
- mindroom/room_cleanup.py +139 -0
- mindroom/routing.py +107 -0
- mindroom/scheduling.py +758 -0
- mindroom/stop.py +207 -0
- mindroom/streaming.py +203 -0
- mindroom/teams.py +749 -0
- mindroom/thread_utils.py +318 -0
- mindroom/tools/__init__.py +520 -0
- mindroom/tools/agentql.py +64 -0
- mindroom/tools/airflow.py +57 -0
- mindroom/tools/apify.py +49 -0
- mindroom/tools/arxiv.py +64 -0
- mindroom/tools/aws_lambda.py +41 -0
- mindroom/tools/aws_ses.py +57 -0
- mindroom/tools/baidusearch.py +87 -0
- mindroom/tools/brightdata.py +116 -0
- mindroom/tools/browserbase.py +62 -0
- mindroom/tools/cal_com.py +98 -0
- mindroom/tools/calculator.py +112 -0
- mindroom/tools/cartesia.py +84 -0
- mindroom/tools/composio.py +166 -0
- mindroom/tools/config_manager.py +44 -0
- mindroom/tools/confluence.py +73 -0
- mindroom/tools/crawl4ai.py +101 -0
- mindroom/tools/csv.py +104 -0
- mindroom/tools/custom_api.py +106 -0
- mindroom/tools/dalle.py +85 -0
- mindroom/tools/daytona.py +180 -0
- mindroom/tools/discord.py +81 -0
- mindroom/tools/docker.py +73 -0
- mindroom/tools/duckdb.py +124 -0
- mindroom/tools/duckduckgo.py +99 -0
- mindroom/tools/e2b.py +121 -0
- mindroom/tools/eleven_labs.py +77 -0
- mindroom/tools/email.py +74 -0
- mindroom/tools/exa.py +246 -0
- mindroom/tools/fal.py +50 -0
- mindroom/tools/file.py +80 -0
- mindroom/tools/financial_datasets_api.py +112 -0
- mindroom/tools/firecrawl.py +124 -0
- mindroom/tools/gemini.py +85 -0
- mindroom/tools/giphy.py +49 -0
- mindroom/tools/github.py +376 -0
- mindroom/tools/gmail.py +102 -0
- mindroom/tools/google_calendar.py +55 -0
- mindroom/tools/google_maps.py +112 -0
- mindroom/tools/google_sheets.py +86 -0
- mindroom/tools/googlesearch.py +83 -0
- mindroom/tools/groq.py +77 -0
- mindroom/tools/hackernews.py +54 -0
- mindroom/tools/jina.py +108 -0
- mindroom/tools/jira.py +70 -0
- mindroom/tools/linear.py +103 -0
- mindroom/tools/linkup.py +65 -0
- mindroom/tools/lumalabs.py +71 -0
- mindroom/tools/mem0.py +82 -0
- mindroom/tools/modelslabs.py +85 -0
- mindroom/tools/moviepy_video_tools.py +62 -0
- mindroom/tools/newspaper4k.py +63 -0
- mindroom/tools/openai.py +143 -0
- mindroom/tools/openweather.py +89 -0
- mindroom/tools/oxylabs.py +54 -0
- mindroom/tools/pandas.py +35 -0
- mindroom/tools/pubmed.py +64 -0
- mindroom/tools/python.py +120 -0
- mindroom/tools/reddit.py +155 -0
- mindroom/tools/replicate.py +56 -0
- mindroom/tools/resend.py +55 -0
- mindroom/tools/scrapegraph.py +87 -0
- mindroom/tools/searxng.py +120 -0
- mindroom/tools/serpapi.py +55 -0
- mindroom/tools/serper.py +81 -0
- mindroom/tools/shell.py +46 -0
- mindroom/tools/slack.py +80 -0
- mindroom/tools/sleep.py +38 -0
- mindroom/tools/spider.py +62 -0
- mindroom/tools/sql.py +138 -0
- mindroom/tools/tavily.py +104 -0
- mindroom/tools/telegram.py +54 -0
- mindroom/tools/todoist.py +103 -0
- mindroom/tools/trello.py +121 -0
- mindroom/tools/twilio.py +97 -0
- mindroom/tools/web_browser_tools.py +37 -0
- mindroom/tools/webex.py +63 -0
- mindroom/tools/website.py +45 -0
- mindroom/tools/whatsapp.py +81 -0
- mindroom/tools/wikipedia.py +45 -0
- mindroom/tools/x.py +97 -0
- mindroom/tools/yfinance.py +121 -0
- mindroom/tools/youtube.py +81 -0
- mindroom/tools/zendesk.py +62 -0
- mindroom/tools/zep.py +107 -0
- mindroom/tools/zoom.py +62 -0
- mindroom/tools_metadata.json +7643 -0
- mindroom/tools_metadata.py +220 -0
- mindroom/topic_generator.py +153 -0
- mindroom/voice_handler.py +266 -0
- mindroom-0.1.0.dist-info/METADATA +425 -0
- mindroom-0.1.0.dist-info/RECORD +152 -0
- {mindroom-0.0.0.dist-info → mindroom-0.1.0.dist-info}/WHEEL +1 -2
- mindroom-0.1.0.dist-info/entry_points.txt +2 -0
- mindroom-0.0.0.dist-info/METADATA +0 -24
- mindroom-0.0.0.dist-info/RECORD +0 -4
- mindroom-0.0.0.dist-info/top_level.txt +0 -1
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
"""Matrix client operations and utilities."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import os
|
|
5
|
+
import ssl as ssl_module
|
|
6
|
+
from collections.abc import AsyncGenerator
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import markdown
|
|
12
|
+
import nio
|
|
13
|
+
|
|
14
|
+
from mindroom.constants import ENCRYPTION_KEYS_DIR
|
|
15
|
+
from mindroom.logging_config import get_logger
|
|
16
|
+
|
|
17
|
+
from .event_info import EventInfo
|
|
18
|
+
from .identity import MatrixID, extract_server_name_from_homeserver
|
|
19
|
+
from .large_messages import prepare_large_message
|
|
20
|
+
from .message_content import extract_and_resolve_message
|
|
21
|
+
|
|
22
|
+
logger = get_logger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _maybe_ssl_context(homeserver: str) -> ssl_module.SSLContext | None:
|
|
26
|
+
if homeserver.startswith("https://"):
|
|
27
|
+
if os.getenv("MATRIX_SSL_VERIFY", "true").lower() == "false":
|
|
28
|
+
# Create context that disables verification for dev/self-signed certs
|
|
29
|
+
ssl_context = ssl_module.create_default_context()
|
|
30
|
+
ssl_context.check_hostname = False
|
|
31
|
+
ssl_context.verify_mode = ssl_module.CERT_NONE
|
|
32
|
+
else:
|
|
33
|
+
# Use default context with proper verification
|
|
34
|
+
ssl_context = ssl_module.create_default_context()
|
|
35
|
+
return ssl_context
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def create_matrix_client(
|
|
40
|
+
homeserver: str,
|
|
41
|
+
user_id: str | None = None,
|
|
42
|
+
access_token: str | None = None,
|
|
43
|
+
store_path: str | None = None,
|
|
44
|
+
) -> nio.AsyncClient:
|
|
45
|
+
"""Create a Matrix client with consistent configuration.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
homeserver: The Matrix homeserver URL
|
|
49
|
+
user_id: Optional user ID for authenticated client
|
|
50
|
+
access_token: Optional access token for authenticated client
|
|
51
|
+
store_path: Optional path for encryption key storage (defaults to .nio_store/<user_id>)
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
nio.AsyncClient: Configured Matrix client instance
|
|
55
|
+
|
|
56
|
+
"""
|
|
57
|
+
ssl_context = _maybe_ssl_context(homeserver)
|
|
58
|
+
|
|
59
|
+
# Default store path for encryption support
|
|
60
|
+
if store_path is None and user_id:
|
|
61
|
+
safe_user_id = user_id.replace(":", "_").replace("@", "")
|
|
62
|
+
store_path = str(ENCRYPTION_KEYS_DIR / safe_user_id)
|
|
63
|
+
# Ensure the directory exists
|
|
64
|
+
Path(store_path).mkdir(parents=True, exist_ok=True)
|
|
65
|
+
|
|
66
|
+
client = nio.AsyncClient(homeserver, user_id, store_path=store_path, ssl=ssl_context)
|
|
67
|
+
|
|
68
|
+
# Manually set user_id due to matrix-nio bug where constructor parameter doesn't work
|
|
69
|
+
# See: https://github.com/matrix-nio/matrix-nio/issues/492
|
|
70
|
+
if user_id:
|
|
71
|
+
client.user_id = user_id
|
|
72
|
+
|
|
73
|
+
if access_token:
|
|
74
|
+
client.access_token = access_token
|
|
75
|
+
|
|
76
|
+
return client
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@asynccontextmanager
|
|
80
|
+
async def matrix_client(
|
|
81
|
+
homeserver: str,
|
|
82
|
+
user_id: str | None = None,
|
|
83
|
+
access_token: str | None = None,
|
|
84
|
+
) -> AsyncGenerator[nio.AsyncClient, None]:
|
|
85
|
+
"""Context manager for Matrix client that ensures proper cleanup.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
homeserver: The Matrix homeserver URL
|
|
89
|
+
user_id: Optional user ID for authenticated client
|
|
90
|
+
access_token: Optional access token for authenticated client
|
|
91
|
+
|
|
92
|
+
Yields:
|
|
93
|
+
nio.AsyncClient: The Matrix client instance
|
|
94
|
+
|
|
95
|
+
Example:
|
|
96
|
+
async with matrix_client("http://localhost:8008") as client:
|
|
97
|
+
response = await client.login(password="secret")
|
|
98
|
+
|
|
99
|
+
"""
|
|
100
|
+
client = create_matrix_client(homeserver, user_id, access_token)
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
yield client
|
|
104
|
+
finally:
|
|
105
|
+
await client.close()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def login(homeserver: str, user_id: str, password: str) -> nio.AsyncClient:
|
|
109
|
+
"""Login to Matrix and return authenticated client.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
homeserver: The Matrix homeserver URL
|
|
113
|
+
user_id: The full Matrix user ID (e.g., @user:localhost)
|
|
114
|
+
password: The user's password
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Authenticated AsyncClient instance
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
ValueError: If login fails
|
|
121
|
+
|
|
122
|
+
"""
|
|
123
|
+
client = create_matrix_client(homeserver, user_id)
|
|
124
|
+
|
|
125
|
+
response = await client.login(password)
|
|
126
|
+
if isinstance(response, nio.LoginResponse):
|
|
127
|
+
logger.info(f"Successfully logged in: {user_id}")
|
|
128
|
+
return client
|
|
129
|
+
await client.close()
|
|
130
|
+
msg = f"Failed to login {user_id}: {response}"
|
|
131
|
+
raise ValueError(msg)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def register_user(
|
|
135
|
+
homeserver: str,
|
|
136
|
+
username: str,
|
|
137
|
+
password: str,
|
|
138
|
+
display_name: str,
|
|
139
|
+
) -> str:
|
|
140
|
+
"""Register a new Matrix user account.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
homeserver: The Matrix homeserver URL
|
|
144
|
+
username: The username for the Matrix account (without domain)
|
|
145
|
+
password: The password for the account
|
|
146
|
+
display_name: The display name for the user
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
The full Matrix user ID (e.g., @user:localhost)
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
ValueError: If registration fails
|
|
153
|
+
|
|
154
|
+
"""
|
|
155
|
+
# Extract server name from homeserver URL
|
|
156
|
+
server_name = extract_server_name_from_homeserver(homeserver)
|
|
157
|
+
user_id = MatrixID.from_username(username, server_name).full_id
|
|
158
|
+
|
|
159
|
+
async with matrix_client(homeserver) as client:
|
|
160
|
+
# Try to register the user
|
|
161
|
+
response = await client.register(
|
|
162
|
+
username=username,
|
|
163
|
+
password=password,
|
|
164
|
+
device_name="mindroom_agent",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
if isinstance(response, nio.RegisterResponse):
|
|
168
|
+
logger.info(f"✅ Successfully registered user: {user_id}")
|
|
169
|
+
# After registration, we already have an access token
|
|
170
|
+
client.user_id = response.user_id
|
|
171
|
+
client.access_token = response.access_token
|
|
172
|
+
client.device_id = response.device_id
|
|
173
|
+
|
|
174
|
+
# Set display name using the existing session
|
|
175
|
+
display_response = await client.set_displayname(display_name)
|
|
176
|
+
if isinstance(display_response, nio.ErrorResponse):
|
|
177
|
+
logger.warning(f"Failed to set display name: {display_response}")
|
|
178
|
+
|
|
179
|
+
return user_id
|
|
180
|
+
if isinstance(response, nio.ErrorResponse) and response.status_code == "M_USER_IN_USE":
|
|
181
|
+
logger.info(f"User {user_id} already exists")
|
|
182
|
+
return user_id
|
|
183
|
+
msg = f"Failed to register user {username}: {response}"
|
|
184
|
+
raise ValueError(msg)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
async def invite_to_room(
|
|
188
|
+
client: nio.AsyncClient,
|
|
189
|
+
room_id: str,
|
|
190
|
+
user_id: str,
|
|
191
|
+
) -> bool:
|
|
192
|
+
"""Invite a user to a room.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
client: Authenticated Matrix client
|
|
196
|
+
room_id: The room to invite to
|
|
197
|
+
user_id: The user to invite
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
True if successful, False otherwise
|
|
201
|
+
|
|
202
|
+
"""
|
|
203
|
+
response = await client.room_invite(room_id, user_id)
|
|
204
|
+
if isinstance(response, nio.RoomInviteResponse):
|
|
205
|
+
logger.info(f"Invited {user_id} to room {room_id}")
|
|
206
|
+
return True
|
|
207
|
+
logger.error(f"Failed to invite {user_id} to room {room_id}: {response}")
|
|
208
|
+
return False
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
async def create_room(
|
|
212
|
+
client: nio.AsyncClient,
|
|
213
|
+
name: str,
|
|
214
|
+
alias: str | None = None,
|
|
215
|
+
topic: str | None = None,
|
|
216
|
+
power_users: list[str] | None = None,
|
|
217
|
+
) -> str | None:
|
|
218
|
+
"""Create a new Matrix room.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
client: Authenticated Matrix client
|
|
222
|
+
name: Room name
|
|
223
|
+
alias: Optional room alias (without # and domain)
|
|
224
|
+
topic: Optional room topic
|
|
225
|
+
power_users: Optional list of user IDs to grant power level 50
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
Room ID if successful, None otherwise
|
|
229
|
+
|
|
230
|
+
"""
|
|
231
|
+
room_config: dict[str, Any] = {"name": name}
|
|
232
|
+
if alias:
|
|
233
|
+
room_config["alias"] = alias
|
|
234
|
+
if topic:
|
|
235
|
+
room_config["topic"] = topic
|
|
236
|
+
|
|
237
|
+
if power_users:
|
|
238
|
+
power_level_content: dict[str, Any] = {
|
|
239
|
+
"users": dict.fromkeys(power_users, 50),
|
|
240
|
+
"state_default": 50, # Set default required power for state events
|
|
241
|
+
}
|
|
242
|
+
# Ensure the creator is an admin
|
|
243
|
+
if client.user_id:
|
|
244
|
+
power_level_content["users"][client.user_id] = 100
|
|
245
|
+
room_config["initial_state"] = [{"type": "m.room.power_levels", "content": power_level_content}]
|
|
246
|
+
|
|
247
|
+
response = await client.room_create(**room_config)
|
|
248
|
+
if isinstance(response, nio.RoomCreateResponse):
|
|
249
|
+
logger.info(f"Created room: {name} ({response.room_id})")
|
|
250
|
+
room_id = str(response.room_id)
|
|
251
|
+
|
|
252
|
+
# Invite power users to the room
|
|
253
|
+
if power_users:
|
|
254
|
+
for user_id in power_users:
|
|
255
|
+
# Skip inviting ourselves
|
|
256
|
+
if user_id != client.user_id:
|
|
257
|
+
await invite_to_room(client, room_id, user_id)
|
|
258
|
+
|
|
259
|
+
return room_id
|
|
260
|
+
logger.error(f"Failed to create room {name}: {response}")
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
async def create_dm_room(
|
|
265
|
+
client: nio.AsyncClient,
|
|
266
|
+
invite_user_ids: list[str],
|
|
267
|
+
name: str | None = None,
|
|
268
|
+
) -> str | None:
|
|
269
|
+
"""Create a Direct Message room with specific users.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
client: Authenticated Matrix client
|
|
273
|
+
invite_user_ids: List of user IDs to invite to the DM
|
|
274
|
+
name: Optional room name (defaults to "Direct Message")
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
Room ID if successful, None otherwise
|
|
278
|
+
|
|
279
|
+
"""
|
|
280
|
+
room_config: dict[str, Any] = {
|
|
281
|
+
"preset": "trusted_private_chat", # DM preset - no need to invite, they can join
|
|
282
|
+
"is_direct": True, # Mark as DM
|
|
283
|
+
"invite": invite_user_ids,
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if name:
|
|
287
|
+
room_config["name"] = name
|
|
288
|
+
|
|
289
|
+
response = await client.room_create(**room_config)
|
|
290
|
+
if isinstance(response, nio.RoomCreateResponse):
|
|
291
|
+
logger.info(f"Created DM room: {response.room_id}")
|
|
292
|
+
return str(response.room_id)
|
|
293
|
+
|
|
294
|
+
logger.error(f"Failed to create DM room: {response}")
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
async def join_room(client: nio.AsyncClient, room_id: str) -> bool:
|
|
299
|
+
"""Join a Matrix room.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
client: Authenticated Matrix client
|
|
303
|
+
room_id: Room ID or alias to join
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
True if successful, False otherwise
|
|
307
|
+
|
|
308
|
+
"""
|
|
309
|
+
response = await client.join(room_id)
|
|
310
|
+
if isinstance(response, nio.JoinResponse):
|
|
311
|
+
logger.info(f"Joined room: {room_id}")
|
|
312
|
+
return True
|
|
313
|
+
logger.warning(f"Could not join room {room_id}: {response}")
|
|
314
|
+
return False
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
async def get_room_members(client: nio.AsyncClient, room_id: str) -> set[str]:
|
|
318
|
+
"""Get the current members of a room.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
client: Authenticated Matrix client
|
|
322
|
+
room_id: The room ID
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
Set of user IDs in the room
|
|
326
|
+
|
|
327
|
+
"""
|
|
328
|
+
response = await client.joined_members(room_id)
|
|
329
|
+
if isinstance(response, nio.JoinedMembersResponse):
|
|
330
|
+
return {member.user_id for member in response.members}
|
|
331
|
+
logger.warning(f"⚠️ Could not check members for room {room_id}")
|
|
332
|
+
return set()
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
async def get_joined_rooms(client: nio.AsyncClient) -> list[str] | None:
|
|
336
|
+
"""Get all rooms the client has joined.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
client: Authenticated Matrix client
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
List of room IDs the client has joined, or None if the request failed
|
|
343
|
+
|
|
344
|
+
"""
|
|
345
|
+
response = await client.joined_rooms()
|
|
346
|
+
if isinstance(response, nio.JoinedRoomsResponse):
|
|
347
|
+
return list(response.rooms)
|
|
348
|
+
logger.error(f"Failed to get joined rooms: {response}")
|
|
349
|
+
return None
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
async def get_room_name(client: nio.AsyncClient, room_id: str) -> str:
|
|
353
|
+
"""Get the display name of a Matrix room.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
client: Authenticated Matrix client
|
|
357
|
+
room_id: The room ID to get the name for
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
Room name if found, fallback name for DM/unnamed rooms
|
|
361
|
+
|
|
362
|
+
"""
|
|
363
|
+
# Try to get the room name directly
|
|
364
|
+
response = await client.room_get_state_event(room_id, "m.room.name")
|
|
365
|
+
if isinstance(response, nio.RoomGetStateEventResponse) and response.content.get("name"):
|
|
366
|
+
return str(response.content["name"])
|
|
367
|
+
|
|
368
|
+
# Get room state for fallback naming
|
|
369
|
+
response = await client.room_get_state(room_id)
|
|
370
|
+
if not isinstance(response, nio.RoomGetStateResponse):
|
|
371
|
+
return "Unnamed Room"
|
|
372
|
+
|
|
373
|
+
# Check for room name in state events
|
|
374
|
+
for event in response.events:
|
|
375
|
+
if event.get("type") == "m.room.name" and event.get("content", {}).get("name"):
|
|
376
|
+
return str(event["content"]["name"])
|
|
377
|
+
|
|
378
|
+
# Build member list for DM/group room names
|
|
379
|
+
members = [
|
|
380
|
+
event.get("content", {}).get("displayname", event.get("state_key", ""))
|
|
381
|
+
for event in response.events
|
|
382
|
+
if event.get("type") == "m.room.member"
|
|
383
|
+
and event.get("content", {}).get("membership") == "join"
|
|
384
|
+
and event.get("state_key") != client.user_id
|
|
385
|
+
]
|
|
386
|
+
|
|
387
|
+
if len(members) == 1:
|
|
388
|
+
return f"DM with {members[0]}"
|
|
389
|
+
if members:
|
|
390
|
+
return f"Room with {', '.join(members[:3])}" + (" and others" if len(members) > 3 else "")
|
|
391
|
+
|
|
392
|
+
return "Unnamed Room"
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
async def leave_room(client: nio.AsyncClient, room_id: str) -> bool:
|
|
396
|
+
"""Leave a Matrix room.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
client: Authenticated Matrix client
|
|
400
|
+
room_id: The room ID to leave
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
True if successfully left the room, False otherwise
|
|
404
|
+
|
|
405
|
+
"""
|
|
406
|
+
response = await client.room_leave(room_id)
|
|
407
|
+
if isinstance(response, nio.RoomLeaveResponse):
|
|
408
|
+
logger.info(f"Left room {room_id}")
|
|
409
|
+
return True
|
|
410
|
+
logger.error(f"Failed to leave room {room_id}: {response}")
|
|
411
|
+
return False
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
async def send_message(client: nio.AsyncClient, room_id: str, content: dict[str, Any]) -> str | None:
|
|
415
|
+
"""Send a message to a Matrix room.
|
|
416
|
+
|
|
417
|
+
Automatically handles large messages that exceed the Matrix event size limit
|
|
418
|
+
by uploading the full content as MXC and sending a maximum-size preview.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
client: Authenticated Matrix client
|
|
422
|
+
room_id: The room ID to send the message to
|
|
423
|
+
content: The message content dictionary
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
The event ID of the sent message, or None if sending failed
|
|
427
|
+
|
|
428
|
+
"""
|
|
429
|
+
# Handle large messages if needed
|
|
430
|
+
content = await prepare_large_message(client, room_id, content)
|
|
431
|
+
|
|
432
|
+
response = await client.room_send(
|
|
433
|
+
room_id=room_id,
|
|
434
|
+
message_type="m.room.message",
|
|
435
|
+
content=content,
|
|
436
|
+
)
|
|
437
|
+
if isinstance(response, nio.RoomSendResponse):
|
|
438
|
+
logger.debug(f"Sent message to {room_id}: {response.event_id}")
|
|
439
|
+
return str(response.event_id)
|
|
440
|
+
logger.error(f"Failed to send message to {room_id}: {response}")
|
|
441
|
+
return None
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
async def fetch_thread_history(
|
|
445
|
+
client: nio.AsyncClient,
|
|
446
|
+
room_id: str,
|
|
447
|
+
thread_id: str,
|
|
448
|
+
) -> list[dict[str, Any]]:
|
|
449
|
+
"""Fetch all messages in a thread.
|
|
450
|
+
|
|
451
|
+
Args:
|
|
452
|
+
client: The Matrix client instance
|
|
453
|
+
room_id: The room ID to fetch messages from
|
|
454
|
+
thread_id: The thread root event ID
|
|
455
|
+
|
|
456
|
+
Returns:
|
|
457
|
+
List of messages in chronological order, each containing sender, body, timestamp, and event_id
|
|
458
|
+
|
|
459
|
+
"""
|
|
460
|
+
messages = []
|
|
461
|
+
from_token = None
|
|
462
|
+
root_message_found = False
|
|
463
|
+
|
|
464
|
+
while True:
|
|
465
|
+
response = await client.room_messages(
|
|
466
|
+
room_id,
|
|
467
|
+
start=from_token,
|
|
468
|
+
limit=100,
|
|
469
|
+
message_filter={"types": ["m.room.message"]},
|
|
470
|
+
direction=nio.MessageDirection.back,
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
if not isinstance(response, nio.RoomMessagesResponse):
|
|
474
|
+
logger.error("Failed to fetch thread history", room_id=room_id, error=str(response))
|
|
475
|
+
break
|
|
476
|
+
|
|
477
|
+
# Break if no new messages found
|
|
478
|
+
if not response.chunk:
|
|
479
|
+
break
|
|
480
|
+
|
|
481
|
+
thread_messages_found = 0
|
|
482
|
+
for event in response.chunk:
|
|
483
|
+
if isinstance(event, nio.RoomMessageText):
|
|
484
|
+
if event.event_id == thread_id and not root_message_found:
|
|
485
|
+
message_data = await extract_and_resolve_message(event, client)
|
|
486
|
+
messages.append(message_data)
|
|
487
|
+
root_message_found = True
|
|
488
|
+
thread_messages_found += 1
|
|
489
|
+
else:
|
|
490
|
+
event_info = EventInfo.from_event(event.source)
|
|
491
|
+
if event_info.is_thread and event_info.thread_id == thread_id:
|
|
492
|
+
message_data = await extract_and_resolve_message(event, client)
|
|
493
|
+
messages.append(message_data)
|
|
494
|
+
thread_messages_found += 1
|
|
495
|
+
|
|
496
|
+
if not response.end or thread_messages_found == 0:
|
|
497
|
+
break
|
|
498
|
+
from_token = response.end
|
|
499
|
+
|
|
500
|
+
return list(reversed(messages)) # Return in chronological order
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
async def _latest_thread_event_id(
|
|
504
|
+
client: nio.AsyncClient,
|
|
505
|
+
room_id: str,
|
|
506
|
+
thread_id: str,
|
|
507
|
+
) -> str:
|
|
508
|
+
"""Get the latest event ID in a thread for MSC3440 fallback compliance.
|
|
509
|
+
|
|
510
|
+
This function fetches the thread history and returns the latest event ID.
|
|
511
|
+
If the thread has no messages yet, returns the thread_id itself as fallback.
|
|
512
|
+
|
|
513
|
+
Args:
|
|
514
|
+
client: Matrix client
|
|
515
|
+
room_id: Room ID
|
|
516
|
+
thread_id: Thread root event ID
|
|
517
|
+
|
|
518
|
+
Returns:
|
|
519
|
+
The latest event ID in the thread, or thread_id if thread is empty
|
|
520
|
+
|
|
521
|
+
"""
|
|
522
|
+
thread_msgs = await fetch_thread_history(client, room_id, thread_id)
|
|
523
|
+
if thread_msgs:
|
|
524
|
+
last_event_id = thread_msgs[-1].get("event_id")
|
|
525
|
+
return str(last_event_id) if last_event_id else thread_id
|
|
526
|
+
return thread_id
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
async def get_latest_thread_event_id_if_needed(
|
|
530
|
+
client: nio.AsyncClient | None,
|
|
531
|
+
room_id: str,
|
|
532
|
+
thread_id: str | None,
|
|
533
|
+
reply_to_event_id: str | None = None,
|
|
534
|
+
existing_event_id: str | None = None,
|
|
535
|
+
) -> str | None:
|
|
536
|
+
"""Get the latest thread event ID only when needed for MSC3440 compliance.
|
|
537
|
+
|
|
538
|
+
This helper encapsulates the common pattern of conditionally fetching
|
|
539
|
+
the latest thread event ID based on various conditions.
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
client: Matrix client (can be None)
|
|
543
|
+
room_id: Room ID
|
|
544
|
+
thread_id: Thread root event ID (can be None)
|
|
545
|
+
reply_to_event_id: Event ID being replied to (if any)
|
|
546
|
+
existing_event_id: Existing event ID being edited (if any)
|
|
547
|
+
|
|
548
|
+
Returns:
|
|
549
|
+
The latest event ID in the thread if needed, None otherwise
|
|
550
|
+
|
|
551
|
+
"""
|
|
552
|
+
# Only fetch latest thread event when:
|
|
553
|
+
# 1. We have a thread_id
|
|
554
|
+
# 2. We have a client
|
|
555
|
+
# 3. We're not editing an existing message
|
|
556
|
+
# 4. We're not making a genuine reply
|
|
557
|
+
if thread_id and client and not existing_event_id and not reply_to_event_id:
|
|
558
|
+
return await _latest_thread_event_id(client, room_id, thread_id)
|
|
559
|
+
return None
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def markdown_to_html(text: str) -> str:
|
|
563
|
+
"""Convert markdown text to HTML for Matrix formatted messages.
|
|
564
|
+
|
|
565
|
+
Args:
|
|
566
|
+
text: The markdown text to convert
|
|
567
|
+
|
|
568
|
+
Returns:
|
|
569
|
+
HTML formatted text
|
|
570
|
+
|
|
571
|
+
"""
|
|
572
|
+
# Configure markdown with common extensions
|
|
573
|
+
md = markdown.Markdown(
|
|
574
|
+
extensions=[
|
|
575
|
+
"markdown.extensions.fenced_code",
|
|
576
|
+
"markdown.extensions.codehilite",
|
|
577
|
+
"markdown.extensions.tables",
|
|
578
|
+
"markdown.extensions.nl2br",
|
|
579
|
+
],
|
|
580
|
+
extension_configs={
|
|
581
|
+
"markdown.extensions.codehilite": {
|
|
582
|
+
"use_pygments": True, # Don't use pygments for syntax highlighting
|
|
583
|
+
"noclasses": True, # Use inline styles instead of CSS classes
|
|
584
|
+
},
|
|
585
|
+
},
|
|
586
|
+
)
|
|
587
|
+
html_text: str = md.convert(text)
|
|
588
|
+
return html_text
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
async def edit_message(
|
|
592
|
+
client: nio.AsyncClient,
|
|
593
|
+
room_id: str,
|
|
594
|
+
event_id: str,
|
|
595
|
+
new_content: dict[str, Any],
|
|
596
|
+
new_text: str,
|
|
597
|
+
) -> str | None:
|
|
598
|
+
"""Edit an existing Matrix message.
|
|
599
|
+
|
|
600
|
+
Automatically handles large messages that exceed the Matrix event size limit
|
|
601
|
+
by uploading the full content as MXC and sending a maximum-size preview.
|
|
602
|
+
|
|
603
|
+
Args:
|
|
604
|
+
client: The Matrix client
|
|
605
|
+
room_id: The room ID where the message is
|
|
606
|
+
event_id: The event ID of the message to edit
|
|
607
|
+
new_content: The new content dictionary (from format_message_with_mentions)
|
|
608
|
+
new_text: The new text (plain text version)
|
|
609
|
+
|
|
610
|
+
Returns:
|
|
611
|
+
The event ID of the edit message, or None if editing failed
|
|
612
|
+
|
|
613
|
+
"""
|
|
614
|
+
edit_content = {
|
|
615
|
+
"msgtype": "m.text",
|
|
616
|
+
"body": f"* {new_text}",
|
|
617
|
+
"format": "org.matrix.custom.html",
|
|
618
|
+
"formatted_body": new_content.get("formatted_body", new_text),
|
|
619
|
+
"m.new_content": new_content,
|
|
620
|
+
"m.relates_to": {"rel_type": "m.replace", "event_id": event_id},
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
# send_message will handle large messages, including the lower threshold for edits
|
|
624
|
+
return await send_message(client, room_id, edit_content)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
async def _upload_avatar_file(
|
|
628
|
+
client: nio.AsyncClient,
|
|
629
|
+
avatar_path: Path,
|
|
630
|
+
) -> str | None:
|
|
631
|
+
"""Upload an avatar file to the Matrix server.
|
|
632
|
+
|
|
633
|
+
Args:
|
|
634
|
+
client: Authenticated Matrix client
|
|
635
|
+
avatar_path: Path to the avatar image file
|
|
636
|
+
|
|
637
|
+
Returns:
|
|
638
|
+
The content URI if successful, None otherwise
|
|
639
|
+
|
|
640
|
+
"""
|
|
641
|
+
if not avatar_path.exists():
|
|
642
|
+
logger.warning(f"Avatar file not found: {avatar_path}")
|
|
643
|
+
return None
|
|
644
|
+
|
|
645
|
+
extension = avatar_path.suffix.lower()
|
|
646
|
+
content_type = {
|
|
647
|
+
".png": "image/png",
|
|
648
|
+
".jpg": "image/jpeg",
|
|
649
|
+
".jpeg": "image/jpeg",
|
|
650
|
+
".webp": "image/webp",
|
|
651
|
+
".gif": "image/gif",
|
|
652
|
+
}.get(extension, "image/png")
|
|
653
|
+
|
|
654
|
+
with avatar_path.open("rb") as f:
|
|
655
|
+
avatar_data = f.read()
|
|
656
|
+
|
|
657
|
+
file_size = len(avatar_data)
|
|
658
|
+
|
|
659
|
+
def data_provider(_upload_monitor: object, _unused_data: object) -> io.BytesIO:
|
|
660
|
+
return io.BytesIO(avatar_data)
|
|
661
|
+
|
|
662
|
+
upload_result = await client.upload(
|
|
663
|
+
data_provider=data_provider,
|
|
664
|
+
content_type=content_type,
|
|
665
|
+
filename=avatar_path.name,
|
|
666
|
+
filesize=file_size,
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
# nio returns tuple (response, error)
|
|
670
|
+
if isinstance(upload_result, tuple):
|
|
671
|
+
upload_response, error = upload_result
|
|
672
|
+
if error:
|
|
673
|
+
logger.error(f"Upload error: {error}")
|
|
674
|
+
return None
|
|
675
|
+
else:
|
|
676
|
+
upload_response = upload_result
|
|
677
|
+
|
|
678
|
+
if not isinstance(upload_response, nio.UploadResponse):
|
|
679
|
+
logger.error(f"Failed to upload avatar: {upload_response}")
|
|
680
|
+
return None
|
|
681
|
+
|
|
682
|
+
if not upload_response.content_uri:
|
|
683
|
+
logger.error("Upload response missing content_uri")
|
|
684
|
+
return None
|
|
685
|
+
|
|
686
|
+
return str(upload_response.content_uri)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
async def set_avatar_from_file(
|
|
690
|
+
client: nio.AsyncClient,
|
|
691
|
+
avatar_path: Path,
|
|
692
|
+
) -> bool:
|
|
693
|
+
"""Set a user's avatar from a local file.
|
|
694
|
+
|
|
695
|
+
Args:
|
|
696
|
+
client: Authenticated Matrix client
|
|
697
|
+
avatar_path: Path to the avatar image file
|
|
698
|
+
|
|
699
|
+
Returns:
|
|
700
|
+
True if successful, False otherwise
|
|
701
|
+
|
|
702
|
+
"""
|
|
703
|
+
avatar_url = await _upload_avatar_file(client, avatar_path)
|
|
704
|
+
if not avatar_url:
|
|
705
|
+
return False
|
|
706
|
+
|
|
707
|
+
response = await client.set_avatar(avatar_url)
|
|
708
|
+
|
|
709
|
+
if isinstance(response, nio.ProfileSetAvatarResponse):
|
|
710
|
+
logger.info(f"✅ Successfully set avatar for {client.user_id}")
|
|
711
|
+
return True
|
|
712
|
+
|
|
713
|
+
logger.error(f"Failed to set avatar for {client.user_id}: {response}")
|
|
714
|
+
return False
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
async def check_and_set_avatar(
|
|
718
|
+
client: nio.AsyncClient,
|
|
719
|
+
avatar_path: Path,
|
|
720
|
+
room_id: str | None = None,
|
|
721
|
+
) -> bool:
|
|
722
|
+
"""Check if user or room has an avatar and set it if they don't.
|
|
723
|
+
|
|
724
|
+
Args:
|
|
725
|
+
client: Authenticated Matrix client
|
|
726
|
+
avatar_path: Path to the avatar image file
|
|
727
|
+
room_id: Optional room ID for setting room avatar (if None, sets user avatar)
|
|
728
|
+
|
|
729
|
+
Returns:
|
|
730
|
+
True if avatar was already set or successfully set, False otherwise
|
|
731
|
+
|
|
732
|
+
"""
|
|
733
|
+
if room_id:
|
|
734
|
+
# Check room avatar
|
|
735
|
+
response = await client.room_get_state_event(room_id, "m.room.avatar")
|
|
736
|
+
if isinstance(response, nio.RoomGetStateEventResponse) and response.content and response.content.get("url"):
|
|
737
|
+
logger.debug(f"Avatar already set for room {room_id}")
|
|
738
|
+
return True
|
|
739
|
+
# Set room avatar
|
|
740
|
+
return await set_room_avatar_from_file(client, room_id, avatar_path)
|
|
741
|
+
# Check user avatar
|
|
742
|
+
response = await client.get_profile(client.user_id)
|
|
743
|
+
if isinstance(response, nio.ProfileGetResponse) and response.avatar_url:
|
|
744
|
+
logger.debug(f"Avatar already set for {client.user_id}")
|
|
745
|
+
return True
|
|
746
|
+
# Set user avatar
|
|
747
|
+
return await set_avatar_from_file(client, avatar_path)
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
async def set_room_avatar_from_file(
|
|
751
|
+
client: nio.AsyncClient,
|
|
752
|
+
room_id: str,
|
|
753
|
+
avatar_path: Path,
|
|
754
|
+
) -> bool:
|
|
755
|
+
"""Set the avatar for a Matrix room from a file.
|
|
756
|
+
|
|
757
|
+
Args:
|
|
758
|
+
client: Authenticated Matrix client
|
|
759
|
+
room_id: The room ID to set the avatar for
|
|
760
|
+
avatar_path: Path to the avatar image file
|
|
761
|
+
|
|
762
|
+
Returns:
|
|
763
|
+
True if avatar was successfully set, False otherwise
|
|
764
|
+
|
|
765
|
+
"""
|
|
766
|
+
avatar_url = await _upload_avatar_file(client, avatar_path)
|
|
767
|
+
if not avatar_url:
|
|
768
|
+
return False
|
|
769
|
+
|
|
770
|
+
# Set room avatar using room state
|
|
771
|
+
response = await client.room_put_state(
|
|
772
|
+
room_id=room_id,
|
|
773
|
+
event_type="m.room.avatar",
|
|
774
|
+
content={"url": avatar_url},
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
if isinstance(response, nio.RoomPutStateResponse):
|
|
778
|
+
logger.info(f"✅ Successfully set avatar for room {room_id}")
|
|
779
|
+
return True
|
|
780
|
+
|
|
781
|
+
logger.error(f"Failed to set avatar for room {room_id}: {response}")
|
|
782
|
+
return False
|