mindroom 0.0.0__py3-none-any.whl → 0.1.1__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.
Files changed (155) hide show
  1. mindroom/__init__.py +3 -0
  2. mindroom/agent_prompts.py +963 -0
  3. mindroom/agents.py +248 -0
  4. mindroom/ai.py +421 -0
  5. mindroom/api/__init__.py +1 -0
  6. mindroom/api/credentials.py +137 -0
  7. mindroom/api/google_integration.py +355 -0
  8. mindroom/api/google_tools_helper.py +40 -0
  9. mindroom/api/homeassistant_integration.py +421 -0
  10. mindroom/api/integrations.py +189 -0
  11. mindroom/api/main.py +506 -0
  12. mindroom/api/matrix_operations.py +219 -0
  13. mindroom/api/tools.py +94 -0
  14. mindroom/background_tasks.py +87 -0
  15. mindroom/bot.py +2470 -0
  16. mindroom/cli.py +86 -0
  17. mindroom/commands.py +377 -0
  18. mindroom/config.py +343 -0
  19. mindroom/config_commands.py +324 -0
  20. mindroom/config_confirmation.py +411 -0
  21. mindroom/constants.py +52 -0
  22. mindroom/credentials.py +146 -0
  23. mindroom/credentials_sync.py +134 -0
  24. mindroom/custom_tools/__init__.py +8 -0
  25. mindroom/custom_tools/config_manager.py +765 -0
  26. mindroom/custom_tools/gmail.py +92 -0
  27. mindroom/custom_tools/google_calendar.py +92 -0
  28. mindroom/custom_tools/google_sheets.py +92 -0
  29. mindroom/custom_tools/homeassistant.py +341 -0
  30. mindroom/error_handling.py +35 -0
  31. mindroom/file_watcher.py +49 -0
  32. mindroom/interactive.py +313 -0
  33. mindroom/logging_config.py +207 -0
  34. mindroom/matrix/__init__.py +1 -0
  35. mindroom/matrix/client.py +782 -0
  36. mindroom/matrix/event_info.py +173 -0
  37. mindroom/matrix/identity.py +149 -0
  38. mindroom/matrix/large_messages.py +267 -0
  39. mindroom/matrix/mentions.py +141 -0
  40. mindroom/matrix/message_builder.py +94 -0
  41. mindroom/matrix/message_content.py +209 -0
  42. mindroom/matrix/presence.py +178 -0
  43. mindroom/matrix/rooms.py +311 -0
  44. mindroom/matrix/state.py +77 -0
  45. mindroom/matrix/typing.py +91 -0
  46. mindroom/matrix/users.py +217 -0
  47. mindroom/memory/__init__.py +21 -0
  48. mindroom/memory/config.py +137 -0
  49. mindroom/memory/functions.py +396 -0
  50. mindroom/py.typed +0 -0
  51. mindroom/response_tracker.py +128 -0
  52. mindroom/room_cleanup.py +139 -0
  53. mindroom/routing.py +107 -0
  54. mindroom/scheduling.py +758 -0
  55. mindroom/stop.py +207 -0
  56. mindroom/streaming.py +203 -0
  57. mindroom/teams.py +749 -0
  58. mindroom/thread_utils.py +318 -0
  59. mindroom/tools/__init__.py +520 -0
  60. mindroom/tools/agentql.py +64 -0
  61. mindroom/tools/airflow.py +57 -0
  62. mindroom/tools/apify.py +49 -0
  63. mindroom/tools/arxiv.py +64 -0
  64. mindroom/tools/aws_lambda.py +41 -0
  65. mindroom/tools/aws_ses.py +57 -0
  66. mindroom/tools/baidusearch.py +87 -0
  67. mindroom/tools/brightdata.py +116 -0
  68. mindroom/tools/browserbase.py +62 -0
  69. mindroom/tools/cal_com.py +98 -0
  70. mindroom/tools/calculator.py +112 -0
  71. mindroom/tools/cartesia.py +84 -0
  72. mindroom/tools/composio.py +166 -0
  73. mindroom/tools/config_manager.py +44 -0
  74. mindroom/tools/confluence.py +73 -0
  75. mindroom/tools/crawl4ai.py +101 -0
  76. mindroom/tools/csv.py +104 -0
  77. mindroom/tools/custom_api.py +106 -0
  78. mindroom/tools/dalle.py +85 -0
  79. mindroom/tools/daytona.py +180 -0
  80. mindroom/tools/discord.py +81 -0
  81. mindroom/tools/docker.py +73 -0
  82. mindroom/tools/duckdb.py +124 -0
  83. mindroom/tools/duckduckgo.py +99 -0
  84. mindroom/tools/e2b.py +121 -0
  85. mindroom/tools/eleven_labs.py +77 -0
  86. mindroom/tools/email.py +74 -0
  87. mindroom/tools/exa.py +246 -0
  88. mindroom/tools/fal.py +50 -0
  89. mindroom/tools/file.py +80 -0
  90. mindroom/tools/financial_datasets_api.py +112 -0
  91. mindroom/tools/firecrawl.py +124 -0
  92. mindroom/tools/gemini.py +85 -0
  93. mindroom/tools/giphy.py +49 -0
  94. mindroom/tools/github.py +376 -0
  95. mindroom/tools/gmail.py +102 -0
  96. mindroom/tools/google_calendar.py +55 -0
  97. mindroom/tools/google_maps.py +112 -0
  98. mindroom/tools/google_sheets.py +86 -0
  99. mindroom/tools/googlesearch.py +83 -0
  100. mindroom/tools/groq.py +77 -0
  101. mindroom/tools/hackernews.py +54 -0
  102. mindroom/tools/jina.py +108 -0
  103. mindroom/tools/jira.py +70 -0
  104. mindroom/tools/linear.py +103 -0
  105. mindroom/tools/linkup.py +65 -0
  106. mindroom/tools/lumalabs.py +71 -0
  107. mindroom/tools/mem0.py +82 -0
  108. mindroom/tools/modelslabs.py +85 -0
  109. mindroom/tools/moviepy_video_tools.py +62 -0
  110. mindroom/tools/newspaper4k.py +63 -0
  111. mindroom/tools/openai.py +143 -0
  112. mindroom/tools/openweather.py +89 -0
  113. mindroom/tools/oxylabs.py +54 -0
  114. mindroom/tools/pandas.py +35 -0
  115. mindroom/tools/pubmed.py +64 -0
  116. mindroom/tools/python.py +120 -0
  117. mindroom/tools/reddit.py +155 -0
  118. mindroom/tools/replicate.py +56 -0
  119. mindroom/tools/resend.py +55 -0
  120. mindroom/tools/scrapegraph.py +87 -0
  121. mindroom/tools/searxng.py +120 -0
  122. mindroom/tools/serpapi.py +55 -0
  123. mindroom/tools/serper.py +81 -0
  124. mindroom/tools/shell.py +46 -0
  125. mindroom/tools/slack.py +80 -0
  126. mindroom/tools/sleep.py +38 -0
  127. mindroom/tools/spider.py +62 -0
  128. mindroom/tools/sql.py +138 -0
  129. mindroom/tools/tavily.py +104 -0
  130. mindroom/tools/telegram.py +54 -0
  131. mindroom/tools/todoist.py +103 -0
  132. mindroom/tools/trello.py +121 -0
  133. mindroom/tools/twilio.py +97 -0
  134. mindroom/tools/web_browser_tools.py +37 -0
  135. mindroom/tools/webex.py +63 -0
  136. mindroom/tools/website.py +45 -0
  137. mindroom/tools/whatsapp.py +81 -0
  138. mindroom/tools/wikipedia.py +45 -0
  139. mindroom/tools/x.py +97 -0
  140. mindroom/tools/yfinance.py +121 -0
  141. mindroom/tools/youtube.py +81 -0
  142. mindroom/tools/zendesk.py +62 -0
  143. mindroom/tools/zep.py +107 -0
  144. mindroom/tools/zoom.py +62 -0
  145. mindroom/tools_metadata.json +7643 -0
  146. mindroom/tools_metadata.py +220 -0
  147. mindroom/topic_generator.py +153 -0
  148. mindroom/voice_handler.py +266 -0
  149. mindroom-0.1.1.dist-info/METADATA +425 -0
  150. mindroom-0.1.1.dist-info/RECORD +152 -0
  151. {mindroom-0.0.0.dist-info → mindroom-0.1.1.dist-info}/WHEEL +1 -2
  152. mindroom-0.1.1.dist-info/entry_points.txt +2 -0
  153. mindroom-0.0.0.dist-info/METADATA +0 -24
  154. mindroom-0.0.0.dist-info/RECORD +0 -4
  155. 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