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,141 @@
1
+ """Matrix mention utilities."""
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ from mindroom.config import Config
7
+
8
+ from .client import markdown_to_html
9
+ from .identity import MatrixID
10
+ from .message_builder import build_message_content
11
+
12
+
13
+ def parse_mentions_in_text(text: str, sender_domain: str, config: Config) -> tuple[str, list[str], str]:
14
+ """Parse text for agent mentions and return processed text with user IDs.
15
+
16
+ Args:
17
+ text: Text that may contain @agent_name mentions
18
+ sender_domain: Domain part of the sender's user ID (e.g., "localhost" from "@user:localhost")
19
+ config: Application configuration
20
+
21
+ Returns:
22
+ Tuple of (plain_text, list_of_mentioned_user_ids, markdown_text_with_links)
23
+
24
+ """
25
+ # Pattern to match @agent_name (with optional @mindroom_ prefix or domain)
26
+ # Matches: @calculator, @mindroom_calculator, @mindroom_calculator:localhost
27
+ pattern = r"@(mindroom_)?(\w+)(?::[^\s]+)?"
28
+
29
+ # Find all mentions and process them
30
+ mentions_data = []
31
+ for match in re.finditer(pattern, text):
32
+ mention_info = _process_mention(match, config, sender_domain)
33
+ if mention_info:
34
+ mentions_data.append(mention_info)
35
+
36
+ # Build outputs from collected data
37
+ plain_text = text
38
+ markdown_text = text
39
+ mentioned_user_ids: list[str] = []
40
+
41
+ # Apply replacements (reverse order to preserve positions)
42
+ for original, user_id, display_name in reversed(mentions_data):
43
+ # Plain text: replace with full Matrix ID
44
+ plain_text = plain_text.replace(original, user_id, 1)
45
+
46
+ # Markdown: replace with clickable link
47
+ link = f"[@{display_name}](https://matrix.to/#/{user_id})"
48
+ markdown_text = markdown_text.replace(original, link, 1)
49
+
50
+ # Collect unique user IDs
51
+ if user_id not in mentioned_user_ids:
52
+ mentioned_user_ids.insert(0, user_id) # Insert at start to maintain order
53
+
54
+ return plain_text, mentioned_user_ids, markdown_text
55
+
56
+
57
+ def _process_mention(match: re.Match, config: Config, sender_domain: str) -> tuple[str, str, str] | None:
58
+ """Process a single mention match and return replacement data.
59
+
60
+ Args:
61
+ match: The regex match object
62
+ config: The loaded config
63
+ sender_domain: Domain for constructing Matrix IDs
64
+
65
+ Returns:
66
+ Tuple of (original_text, matrix_user_id, display_name) or None if not a valid agent
67
+
68
+ """
69
+ original = match.group(0)
70
+ prefix = match.group(1) or "" # "mindroom_" or empty
71
+ name = match.group(2)
72
+
73
+ # Skip user mentions (mindroom_user_*)
74
+ if name.startswith("user_"):
75
+ return None
76
+
77
+ # Try to find the agent (case-insensitive)
78
+ agent_name = None
79
+ name_lower = name.lower()
80
+
81
+ # Check for direct match (case-insensitive)
82
+ for config_agent_name in config.agents:
83
+ if config_agent_name.lower() == name_lower:
84
+ agent_name = config_agent_name
85
+ break
86
+
87
+ # If not found, try with mindroom_ prefix removed
88
+ if not agent_name and prefix:
89
+ name_without_prefix = name.replace("mindroom_", "")
90
+ name_without_prefix_lower = name_without_prefix.lower()
91
+ for config_agent_name in config.agents:
92
+ if config_agent_name.lower() == name_without_prefix_lower:
93
+ agent_name = config_agent_name
94
+ break
95
+
96
+ if agent_name:
97
+ agent_config = config.agents[agent_name]
98
+ user_id = MatrixID.from_agent(agent_name, sender_domain).full_id
99
+ return (original, user_id, agent_config.display_name)
100
+
101
+ return None
102
+
103
+
104
+ def format_message_with_mentions(
105
+ config: Config,
106
+ text: str,
107
+ sender_domain: str = "localhost",
108
+ thread_event_id: str | None = None,
109
+ reply_to_event_id: str | None = None,
110
+ latest_thread_event_id: str | None = None,
111
+ ) -> dict[str, Any]:
112
+ """Parse text for mentions and create properly formatted Matrix message.
113
+
114
+ This is the universal function that should be used everywhere.
115
+
116
+ Args:
117
+ config: Application configuration
118
+ text: Message text that may contain @agent_name mentions
119
+ sender_domain: Domain part of the sender's user ID
120
+ thread_event_id: Optional thread root event ID
121
+ reply_to_event_id: Optional event ID to reply to (for genuine replies)
122
+ latest_thread_event_id: Optional latest event ID in thread (for fallback compatibility)
123
+
124
+ Returns:
125
+ Properly formatted content dict for room_send
126
+
127
+ """
128
+ plain_text, mentioned_user_ids, markdown_text = parse_mentions_in_text(text, sender_domain, config)
129
+
130
+ # Convert markdown (with links) to HTML
131
+ # The markdown converter will properly handle the [@DisplayName](url) format
132
+ formatted_html = markdown_to_html(markdown_text)
133
+
134
+ return build_message_content(
135
+ body=plain_text,
136
+ formatted_body=formatted_html,
137
+ mentioned_user_ids=mentioned_user_ids,
138
+ thread_event_id=thread_event_id,
139
+ reply_to_event_id=reply_to_event_id,
140
+ latest_thread_event_id=latest_thread_event_id,
141
+ )
@@ -0,0 +1,94 @@
1
+ """Matrix message content builder with proper threading support."""
2
+
3
+ from typing import Any
4
+
5
+ from .client import markdown_to_html
6
+
7
+
8
+ def build_thread_relation(
9
+ thread_event_id: str,
10
+ reply_to_event_id: str | None = None,
11
+ latest_thread_event_id: str | None = None,
12
+ ) -> dict[str, Any]:
13
+ """Build the m.relates_to structure for thread messages per MSC3440.
14
+
15
+ Args:
16
+ thread_event_id: The thread root event ID
17
+ reply_to_event_id: Optional event ID for genuine replies within thread
18
+ latest_thread_event_id: Latest event in thread (required for fallback if no reply_to)
19
+
20
+ Returns:
21
+ The m.relates_to structure for the message content
22
+
23
+ """
24
+ if reply_to_event_id:
25
+ # Genuine reply to a specific message in the thread
26
+ return {
27
+ "rel_type": "m.thread",
28
+ "event_id": thread_event_id,
29
+ "is_falling_back": False,
30
+ "m.in_reply_to": {"event_id": reply_to_event_id},
31
+ }
32
+ # Fallback: continuing thread without specific reply
33
+ # Per MSC3440, should point to latest message in thread for backwards compatibility
34
+ assert latest_thread_event_id is not None, "latest_thread_event_id is required for thread fallback"
35
+ return {
36
+ "rel_type": "m.thread",
37
+ "event_id": thread_event_id,
38
+ "is_falling_back": True,
39
+ "m.in_reply_to": {"event_id": latest_thread_event_id},
40
+ }
41
+
42
+
43
+ def build_message_content(
44
+ body: str,
45
+ formatted_body: str | None = None,
46
+ mentioned_user_ids: list[str] | None = None,
47
+ thread_event_id: str | None = None,
48
+ reply_to_event_id: str | None = None,
49
+ latest_thread_event_id: str | None = None,
50
+ ) -> dict[str, Any]:
51
+ """Build a complete Matrix message content dictionary.
52
+
53
+ This handles all the Matrix protocol requirements for messages including:
54
+ - Basic message structure
55
+ - HTML formatting
56
+ - User mentions
57
+ - Thread relations (MSC3440 compliant)
58
+ - Reply relations
59
+
60
+ Args:
61
+ body: The plain text message body
62
+ formatted_body: Optional HTML formatted body (if not provided, converts from markdown)
63
+ mentioned_user_ids: Optional list of Matrix user IDs to mention
64
+ thread_event_id: Optional thread root event ID
65
+ reply_to_event_id: Optional event ID to reply to
66
+ latest_thread_event_id: Optional latest event in thread (for MSC3440 fallback)
67
+
68
+ Returns:
69
+ Complete content dictionary ready for room_send
70
+
71
+ """
72
+ content: dict[str, Any] = {
73
+ "msgtype": "m.text",
74
+ "body": body,
75
+ "format": "org.matrix.custom.html",
76
+ "formatted_body": formatted_body if formatted_body else markdown_to_html(body),
77
+ }
78
+
79
+ # Add mentions if any
80
+ if mentioned_user_ids:
81
+ content["m.mentions"] = {"user_ids": mentioned_user_ids}
82
+
83
+ # Add thread/reply relationship if specified
84
+ if thread_event_id:
85
+ content["m.relates_to"] = build_thread_relation(
86
+ thread_event_id=thread_event_id,
87
+ reply_to_event_id=reply_to_event_id,
88
+ latest_thread_event_id=latest_thread_event_id,
89
+ )
90
+ elif reply_to_event_id:
91
+ # Plain reply without thread (shouldn't happen in this bot)
92
+ content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to_event_id}}
93
+
94
+ return content
@@ -0,0 +1,209 @@
1
+ """Centralized message content extraction with large message support.
2
+
3
+ This module provides utilities to extract the full content from Matrix messages,
4
+ including handling large messages that are stored as MXC attachments.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from typing import Any
11
+
12
+ import nio
13
+ from nio import crypto
14
+
15
+ from mindroom.logging_config import get_logger
16
+
17
+ logger = get_logger(__name__)
18
+
19
+ # MXC download cache - stores (content, timestamp) tuples
20
+ # Key: mxc_url, Value: (content, timestamp)
21
+ _mxc_cache: dict[str, tuple[str, float]] = {}
22
+ _cache_ttl = 3600.0 # 1 hour TTL
23
+
24
+
25
+ async def _get_full_message_body(
26
+ message_data: dict[str, Any],
27
+ client: nio.AsyncClient | None = None,
28
+ ) -> str:
29
+ """Extract the full message body, handling large message attachments.
30
+
31
+ For regular messages, returns the body directly.
32
+ For large messages with attachments, downloads and returns the full content.
33
+
34
+ Args:
35
+ message_data: Dict with message data including 'body' and 'content' keys
36
+ client: Optional Matrix client for downloading attachments
37
+
38
+ Returns:
39
+ The full message body text
40
+
41
+ """
42
+ content = message_data.get("content", {})
43
+ body = str(message_data.get("body", ""))
44
+
45
+ # Check if this is a large message with our custom metadata
46
+ if "io.mindroom.long_text" in content:
47
+ # This is a large message - need to fetch the attachment
48
+ if not client:
49
+ logger.warning("Cannot fetch large message attachment without client, returning preview")
50
+ return body
51
+
52
+ # Get the MXC URL from either 'url' (unencrypted) or 'file' (encrypted)
53
+ mxc_url = None
54
+ if "url" in content:
55
+ mxc_url = content["url"]
56
+ elif "file" in content:
57
+ file_info = content["file"]
58
+ mxc_url = file_info.get("url")
59
+
60
+ if not mxc_url:
61
+ logger.warning("Large message missing MXC URL, returning preview")
62
+ return body
63
+
64
+ # Download the full content
65
+ full_text = await _download_mxc_text(client, mxc_url, content.get("file"))
66
+ if full_text:
67
+ return full_text
68
+ logger.warning("Failed to download large message, returning preview")
69
+ return body
70
+
71
+ # Regular message or no custom metadata
72
+ return body
73
+
74
+
75
+ async def _download_mxc_text( # noqa: PLR0911, C901
76
+ client: nio.AsyncClient,
77
+ mxc_url: str,
78
+ file_info: dict[str, Any] | None = None,
79
+ ) -> str | None:
80
+ """Download text content from an MXC URL with caching.
81
+
82
+ Args:
83
+ client: Matrix client
84
+ mxc_url: The MXC URL to download from
85
+ file_info: Optional encryption info for E2EE rooms
86
+
87
+ Returns:
88
+ The downloaded text content, or None if download failed
89
+
90
+ """
91
+ # Check cache first
92
+ current_time = time.time()
93
+ if mxc_url in _mxc_cache:
94
+ content, timestamp = _mxc_cache[mxc_url]
95
+ if current_time - timestamp < _cache_ttl:
96
+ logger.debug(f"Cache hit for MXC URL: {mxc_url}")
97
+ return content
98
+ # Expired, remove from cache
99
+ del _mxc_cache[mxc_url]
100
+
101
+ try:
102
+ # Parse MXC URL
103
+ if not mxc_url.startswith("mxc://"):
104
+ logger.error(f"Invalid MXC URL: {mxc_url}")
105
+ return None
106
+
107
+ # Extract server and media ID
108
+ parts = mxc_url[6:].split("/", 1)
109
+ if len(parts) != 2:
110
+ logger.error(f"Invalid MXC URL format: {mxc_url}")
111
+ return None
112
+
113
+ server_name, media_id = parts
114
+
115
+ # Download the content
116
+ response = await client.download(server_name, media_id)
117
+
118
+ if not isinstance(response, nio.DownloadResponse):
119
+ logger.error(f"Failed to download MXC content: {response}")
120
+ return None
121
+
122
+ # Handle encryption if needed
123
+ if file_info and "key" in file_info:
124
+ # Decrypt the content
125
+ try:
126
+ decrypted = crypto.attachments.decrypt_attachment(
127
+ response.body,
128
+ file_info["key"],
129
+ file_info["hashes"]["sha256"],
130
+ file_info["iv"],
131
+ )
132
+ text_bytes = decrypted
133
+ except Exception:
134
+ logger.exception("Failed to decrypt attachment")
135
+ return None
136
+ else:
137
+ text_bytes = response.body
138
+
139
+ # Decode to text
140
+ try:
141
+ decoded_text: str = text_bytes.decode("utf-8")
142
+ except UnicodeDecodeError:
143
+ logger.exception("Downloaded content is not valid UTF-8 text")
144
+ return None
145
+
146
+ else:
147
+ # Cache the result
148
+ _mxc_cache[mxc_url] = (decoded_text, time.time())
149
+ logger.debug(f"Cached MXC content for: {mxc_url}")
150
+
151
+ # Clean old entries if cache is getting large
152
+ if len(_mxc_cache) > 100:
153
+ _clean_expired_cache()
154
+
155
+ return decoded_text
156
+
157
+ except Exception:
158
+ logger.exception("Error downloading MXC content")
159
+ return None
160
+
161
+
162
+ async def extract_and_resolve_message(
163
+ event: nio.RoomMessageText,
164
+ client: nio.AsyncClient | None = None,
165
+ ) -> dict[str, Any]:
166
+ """Extract message data and resolve large message content if needed.
167
+
168
+ This is a convenience function that combines extraction and resolution
169
+ of large message content in a single call.
170
+
171
+ Args:
172
+ event: The Matrix event to extract data from
173
+ client: Optional Matrix client for downloading attachments
174
+
175
+ Returns:
176
+ Dict with sender, body, timestamp, event_id, and content fields.
177
+ If the message is large and client is provided, body will contain
178
+ the full text from the attachment.
179
+
180
+ """
181
+ # Extract basic message data
182
+ data = {
183
+ "sender": event.sender,
184
+ "body": event.body,
185
+ "timestamp": event.server_timestamp,
186
+ "event_id": event.event_id,
187
+ "content": event.source.get("content", {}),
188
+ }
189
+
190
+ # Check if this is a large message and resolve if we have a client
191
+ if client and "io.mindroom.long_text" in data["content"]:
192
+ data["body"] = await _get_full_message_body(data, client)
193
+
194
+ return data
195
+
196
+
197
+ def _clean_expired_cache() -> None:
198
+ """Remove expired entries from the MXC cache."""
199
+ current_time = time.time()
200
+ expired_keys = [key for key, (_, timestamp) in _mxc_cache.items() if current_time - timestamp >= _cache_ttl]
201
+ for key in expired_keys:
202
+ del _mxc_cache[key]
203
+ if expired_keys:
204
+ logger.debug(f"Cleaned {len(expired_keys)} expired cache entries")
205
+
206
+
207
+ def clear_mxc_cache() -> None:
208
+ """Clear the entire MXC cache. Useful for testing."""
209
+ _mxc_cache.clear()
@@ -0,0 +1,178 @@
1
+ """Matrix presence and status message utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ import nio
8
+
9
+ from mindroom.constants import ENABLE_STREAMING, ROUTER_AGENT_NAME
10
+ from mindroom.logging_config import get_logger
11
+
12
+ if TYPE_CHECKING:
13
+ from mindroom.config import Config
14
+
15
+ logger = get_logger(__name__)
16
+
17
+
18
+ async def set_presence_status(
19
+ client: nio.AsyncClient,
20
+ status_msg: str,
21
+ presence: str = "online",
22
+ ) -> None:
23
+ """Set presence status for a Matrix user.
24
+
25
+ Args:
26
+ client: The Matrix client
27
+ status_msg: The status message to display
28
+ presence: The presence state (online, offline, unavailable)
29
+
30
+ """
31
+ response = await client.set_presence(presence, status_msg)
32
+
33
+ if isinstance(response, nio.PresenceSetResponse):
34
+ logger.info(f"Set presence status: {status_msg}")
35
+ else:
36
+ logger.warning(f"Failed to set presence: {response}")
37
+
38
+
39
+ def build_agent_status_message(
40
+ agent_name: str,
41
+ config: Config,
42
+ ) -> str:
43
+ """Build status message with model and role information for an agent.
44
+
45
+ Args:
46
+ agent_name: Name of the agent
47
+ config: Application configuration
48
+
49
+ Returns:
50
+ Status message string, limited to 250 characters
51
+
52
+ """
53
+ status_parts = []
54
+
55
+ # Get model name using the config method
56
+ model_name = config.get_entity_model_name(agent_name)
57
+
58
+ # Format model info
59
+ if model_name in config.models:
60
+ model_config = config.models[model_name]
61
+ model_info = f"{model_config.provider}/{model_config.id}"
62
+ else:
63
+ model_info = model_name
64
+
65
+ status_parts.append(f"🤖 Model: {model_info}")
66
+
67
+ # Add role/purpose for teams and agents
68
+ if agent_name == ROUTER_AGENT_NAME:
69
+ status_parts.append("📍 Routes messages to appropriate agents")
70
+ elif agent_name in config.teams:
71
+ team_config = config.teams[agent_name]
72
+ if team_config.role:
73
+ status_parts.append(f"👥 {team_config.role[:100]}") # Limit length
74
+ status_parts.append(f"🤝 Team: {', '.join(team_config.agents[:5])}") # Show first 5 agents
75
+ elif agent_name in config.agents:
76
+ agent_config = config.agents[agent_name]
77
+ if agent_config.role:
78
+ status_parts.append(f"💼 {agent_config.role[:100]}") # Limit length
79
+ # Add tool count
80
+ if agent_config.tools:
81
+ status_parts.append(f"🔧 {len(agent_config.tools)} tools available")
82
+
83
+ # Join all parts with separators
84
+ return " | ".join(status_parts)
85
+
86
+
87
+ async def is_user_online(
88
+ client: nio.AsyncClient,
89
+ user_id: str,
90
+ ) -> bool:
91
+ """Check if a Matrix user is currently online.
92
+
93
+ Args:
94
+ client: The Matrix client to use for the presence check
95
+ user_id: The Matrix user ID string (e.g., "@user:example.com")
96
+
97
+ Returns:
98
+ True if the user is online or unavailable (active but busy),
99
+ False if offline or presence check fails
100
+
101
+ """
102
+ try:
103
+ response = await client.get_presence(user_id)
104
+
105
+ # Check if we got an error response
106
+ if isinstance(response, nio.PresenceGetError):
107
+ logger.warning(
108
+ "Presence API error",
109
+ user_id=user_id,
110
+ error=response.message,
111
+ )
112
+ return False
113
+
114
+ # Presence states: "online", "unavailable" (busy/idle), "offline"
115
+ # We consider both "online" and "unavailable" as "online" for streaming purposes
116
+ # since "unavailable" usually means the user is idle but still has the client open
117
+ is_online = response.presence in ("online", "unavailable")
118
+
119
+ logger.debug(
120
+ "User presence check",
121
+ user_id=user_id,
122
+ presence=response.presence,
123
+ is_online=is_online,
124
+ last_active_ago=response.last_active_ago,
125
+ )
126
+
127
+ return is_online # noqa: TRY300
128
+
129
+ except Exception:
130
+ logger.exception(
131
+ "Error checking user presence",
132
+ user_id=user_id,
133
+ )
134
+ # Default to non-streaming on error (safer)
135
+ return False
136
+
137
+
138
+ async def should_use_streaming(
139
+ client: nio.AsyncClient,
140
+ room_id: str,
141
+ requester_user_id: str | None = None,
142
+ ) -> bool:
143
+ """Determine if streaming should be used based on user presence.
144
+
145
+ This checks if the human user who sent the message is online.
146
+ If they are online, we use streaming (message editing) for real-time updates.
147
+ If they are offline, we send the complete message at once to save API calls.
148
+
149
+ Args:
150
+ client: The Matrix client
151
+ room_id: The room where the interaction is happening
152
+ requester_user_id: The user who sent the message (optional)
153
+
154
+ Returns:
155
+ True if streaming should be used, False otherwise
156
+
157
+ """
158
+ # Check if streaming is globally disabled
159
+ if not ENABLE_STREAMING:
160
+ return False
161
+
162
+ # If no requester specified, we can't check presence, default to streaming
163
+ if not requester_user_id:
164
+ logger.debug("No requester specified, defaulting to streaming")
165
+ return True
166
+
167
+ # Check if the requester is online
168
+ is_online = await is_user_online(client, requester_user_id)
169
+
170
+ logger.info(
171
+ "Streaming decision",
172
+ room_id=room_id,
173
+ requester=requester_user_id,
174
+ is_online=is_online,
175
+ use_streaming=is_online,
176
+ )
177
+
178
+ return is_online