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,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
|