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,173 @@
1
+ """Comprehensive event relation analysis for Matrix events.
2
+
3
+ This module provides a unified API for analyzing all Matrix event relations
4
+ including threads (MSC3440), edits, replies, reactions, and more.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+
12
+ @dataclass
13
+ class EventInfo:
14
+ """Comprehensive analysis of Matrix event relations."""
15
+
16
+ # Thread information (MSC3440)
17
+ is_thread: bool
18
+ """Whether this event is part of a thread."""
19
+
20
+ thread_id: str | None
21
+ """The thread root event ID if this is a thread message."""
22
+
23
+ can_be_thread_root: bool
24
+ """Whether this event can be used as a thread root per MSC3440."""
25
+
26
+ safe_thread_root: str | None
27
+ """Safe event ID to use as thread root (None means use this event)."""
28
+
29
+ # Edit information
30
+ is_edit: bool
31
+ """Whether this event is an edit (m.replace)."""
32
+
33
+ original_event_id: str | None
34
+ """The event ID being edited if this is an edit."""
35
+
36
+ # Reply information
37
+ is_reply: bool
38
+ """Whether this event is a reply to another event."""
39
+
40
+ reply_to_event_id: str | None
41
+ """The event ID being replied to if this is a reply."""
42
+
43
+ # Reaction information
44
+ is_reaction: bool
45
+ """Whether this event is a reaction (m.annotation)."""
46
+
47
+ reaction_key: str | None
48
+ """The reaction key/emoji if this is a reaction."""
49
+
50
+ reaction_target_event_id: str | None
51
+ """The event ID being reacted to if this is a reaction."""
52
+
53
+ # General relation information
54
+ has_relations: bool
55
+ """Whether this event has any relations."""
56
+
57
+ relation_type: str | None
58
+ """The relation type if any (m.replace, m.annotation, m.thread, etc)."""
59
+
60
+ relates_to_event_id: str | None
61
+ """The primary event ID this event relates to (if any)."""
62
+
63
+ @staticmethod
64
+ def from_event(event_source: dict | None) -> EventInfo:
65
+ """Create EventInfo from a raw event source dictionary."""
66
+ return _analyze_event_relations(event_source)
67
+
68
+
69
+ def _analyze_event_relations(event_source: dict | None) -> EventInfo:
70
+ """Analyze complete relation information for a Matrix event.
71
+
72
+ This unified function provides all relation-related information in one place,
73
+ replacing manual extraction of m.relates_to throughout the codebase.
74
+
75
+ Per MSC3440:
76
+ - A thread can only be created from events that don't have any rel_type
77
+ - Thread messages use rel_type: m.thread
78
+ - Edits use rel_type: m.replace
79
+ - Reactions use rel_type: m.annotation
80
+ - Replies can be within threads or standalone
81
+
82
+ Args:
83
+ event_source: The event source dictionary (e.g., event.source for nio events)
84
+
85
+ Returns:
86
+ EventInfo object with complete relation analysis
87
+
88
+ """
89
+ if not event_source:
90
+ return EventInfo(
91
+ is_thread=False,
92
+ thread_id=None,
93
+ can_be_thread_root=True,
94
+ safe_thread_root=None,
95
+ is_edit=False,
96
+ original_event_id=None,
97
+ is_reply=False,
98
+ reply_to_event_id=None,
99
+ is_reaction=False,
100
+ reaction_key=None,
101
+ reaction_target_event_id=None,
102
+ has_relations=False,
103
+ relation_type=None,
104
+ relates_to_event_id=None,
105
+ )
106
+
107
+ content = event_source.get("content", {})
108
+ relates_to = content.get("m.relates_to", {})
109
+
110
+ # Extract basic relation information
111
+ relation_type = relates_to.get("rel_type")
112
+ has_relations = bool(relates_to)
113
+ relates_to_event_id = relates_to.get("event_id")
114
+
115
+ # Thread analysis
116
+ is_thread = relation_type == "m.thread"
117
+ thread_id = relates_to_event_id if is_thread else None
118
+
119
+ # Edit analysis
120
+ is_edit = relation_type == "m.replace"
121
+ original_event_id = relates_to_event_id if is_edit else None
122
+
123
+ # Reaction analysis
124
+ is_reaction = relation_type == "m.annotation"
125
+ reaction_key = relates_to.get("key") if is_reaction else None
126
+ reaction_target_event_id = relates_to_event_id if is_reaction else None
127
+
128
+ # Reply analysis
129
+ # Replies can exist within threads or as standalone
130
+ # They have m.in_reply_to field
131
+ in_reply_to = relates_to.get("m.in_reply_to", {})
132
+ is_reply = bool(in_reply_to and in_reply_to.get("event_id"))
133
+ reply_to_event_id = in_reply_to.get("event_id") if is_reply else None
134
+
135
+ # Determine if this event can be a thread root (per MSC3440)
136
+ # An event can only be a thread root if it has NO relations
137
+ can_be_thread_root = not has_relations
138
+
139
+ # Determine safe thread root for creating new threads
140
+ safe_thread_root = None
141
+ if not can_be_thread_root:
142
+ # This event has relations, so it cannot be a thread root
143
+ # Try to use the target of the relation as the thread root
144
+
145
+ if relation_type in ("m.replace", "m.annotation", "m.reference"):
146
+ # For edits, reactions, and references, use the target event
147
+ if relates_to_event_id:
148
+ safe_thread_root = str(relates_to_event_id)
149
+ elif is_reply and reply_to_event_id:
150
+ # For rich replies, use the event being replied to
151
+ safe_thread_root = str(reply_to_event_id)
152
+
153
+ return EventInfo(
154
+ # Thread info
155
+ is_thread=is_thread,
156
+ thread_id=thread_id,
157
+ can_be_thread_root=can_be_thread_root,
158
+ safe_thread_root=safe_thread_root,
159
+ # Edit info
160
+ is_edit=is_edit,
161
+ original_event_id=original_event_id,
162
+ # Reply info
163
+ is_reply=is_reply,
164
+ reply_to_event_id=reply_to_event_id,
165
+ # Reaction info
166
+ is_reaction=is_reaction,
167
+ reaction_key=reaction_key,
168
+ reaction_target_event_id=reaction_target_event_id,
169
+ # General info
170
+ has_relations=has_relations,
171
+ relation_type=relation_type,
172
+ relates_to_event_id=relates_to_event_id,
173
+ )
@@ -0,0 +1,149 @@
1
+ """Unified Matrix ID handling system."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from functools import lru_cache
7
+ from typing import TYPE_CHECKING, ClassVar
8
+
9
+ from mindroom.constants import MATRIX_SERVER_NAME, ROUTER_AGENT_NAME
10
+
11
+ if TYPE_CHECKING:
12
+ from mindroom.config import Config
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class MatrixID:
17
+ """Immutable Matrix ID representation with parsing and validation."""
18
+
19
+ username: str
20
+ domain: str
21
+
22
+ AGENT_PREFIX: ClassVar[str] = "mindroom_"
23
+
24
+ @classmethod
25
+ def parse(cls, matrix_id: str) -> MatrixID:
26
+ """Parse a Matrix ID like @mindroom_calculator:localhost."""
27
+ return _parse_matrix_id(matrix_id)
28
+
29
+ @classmethod
30
+ def from_agent(cls, agent_name: str, domain: str) -> MatrixID:
31
+ """Create a MatrixID for an agent."""
32
+ return cls(username=f"{cls.AGENT_PREFIX}{agent_name}", domain=domain)
33
+
34
+ @classmethod
35
+ def from_username(cls, username: str, domain: str) -> MatrixID:
36
+ """Create a MatrixID from a username (without @ prefix)."""
37
+ return cls(username=username, domain=domain)
38
+
39
+ @property
40
+ def full_id(self) -> str:
41
+ """Get the full Matrix ID like @mindroom_calculator:localhost."""
42
+ return f"@{self.username}:{self.domain}"
43
+
44
+ def agent_name(self, config: Config) -> str | None:
45
+ """Extract agent name if this is a configured agent ID."""
46
+ if not self.username.startswith(self.AGENT_PREFIX):
47
+ return None
48
+
49
+ # Remove prefix
50
+ name = self.username[len(self.AGENT_PREFIX) :]
51
+
52
+ # Special check for the router agent:
53
+ # The router is a built-in agent that handles command routing and doesn't
54
+ # appear in config.agents. Without this check, extract_agent_name() would
55
+ # return None for router messages, causing other agents to incorrectly
56
+ # respond to router's error messages (e.g., when schedule parsing fails).
57
+ if name == ROUTER_AGENT_NAME:
58
+ return name
59
+
60
+ # Validate against both regular agents and teams
61
+ if name in config.agents:
62
+ return name
63
+ if name in config.teams:
64
+ return name
65
+ return None
66
+
67
+ def __str__(self) -> str:
68
+ """Return the full Matrix ID string representation."""
69
+ return self.full_id
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class ThreadStateKey:
74
+ """Represents a thread state key like 'thread_id:agent_name'."""
75
+
76
+ thread_id: str
77
+ agent_name: str
78
+
79
+ @classmethod
80
+ def parse(cls, state_key: str) -> ThreadStateKey:
81
+ """Parse a state key."""
82
+ parts = state_key.split(":", 1)
83
+ if len(parts) != 2:
84
+ msg = f"Invalid state key: {state_key}"
85
+ raise ValueError(msg)
86
+ return cls(thread_id=parts[0], agent_name=parts[1])
87
+
88
+ @property
89
+ def key(self) -> str:
90
+ """Get the full state key."""
91
+ return f"{self.thread_id}:{self.agent_name}"
92
+
93
+ def __str__(self) -> str:
94
+ """Return the state key string representation."""
95
+ return self.key
96
+
97
+
98
+ @lru_cache(maxsize=512)
99
+ def _parse_matrix_id(matrix_id: str) -> MatrixID:
100
+ """Cached wrapper around MatrixID.parse for performance."""
101
+ if not matrix_id.startswith("@"):
102
+ msg = f"Invalid Matrix ID: {matrix_id}"
103
+ raise ValueError(msg)
104
+ if ":" not in matrix_id:
105
+ msg = f"Invalid Matrix ID, missing domain: {matrix_id}"
106
+ raise ValueError(msg)
107
+ parts = matrix_id[1:].split(":", 1)
108
+ if len(parts) != 2:
109
+ msg = f"Invalid Matrix ID format: {matrix_id}"
110
+ raise ValueError(msg)
111
+
112
+ return MatrixID(username=parts[0], domain=parts[1])
113
+
114
+
115
+ def is_agent_id(matrix_id: str, config: Config) -> bool:
116
+ """Quick check if a Matrix ID is an agent."""
117
+ return extract_agent_name(matrix_id, config) is not None
118
+
119
+
120
+ def extract_agent_name(sender_id: str, config: Config) -> str | None:
121
+ """Extract agent name from Matrix user ID like @mindroom_calculator:localhost.
122
+
123
+ Returns agent name (e.g., 'calculator') or None if not an agent.
124
+ """
125
+ if not sender_id.startswith("@") or ":" not in sender_id:
126
+ return None
127
+ mid = MatrixID.parse(sender_id)
128
+ return mid.agent_name(config)
129
+
130
+
131
+ def extract_server_name_from_homeserver(homeserver: str) -> str:
132
+ """Extract server name from a homeserver URL like "http://localhost:8008".
133
+
134
+ If MATRIX_SERVER_NAME environment variable is set, use that instead.
135
+ This is needed for federation setups where the internal hostname differs
136
+ from the actual Matrix server name.
137
+ """
138
+ # Check for explicit server name override (for federation/docker setups)
139
+ if MATRIX_SERVER_NAME:
140
+ return MATRIX_SERVER_NAME
141
+
142
+ # Otherwise extract from homeserver URL
143
+ # Remove protocol
144
+ server_part = homeserver.split("://", 1)[1] if "://" in homeserver else homeserver
145
+
146
+ # Remove port if present
147
+ if ":" in server_part:
148
+ return server_part.split(":", 1)[0]
149
+ return server_part
@@ -0,0 +1,267 @@
1
+ """Handle large Matrix messages that exceed the 64KB event limit.
2
+
3
+ This module provides minimal intervention for messages that are too large,
4
+ uploading the full text as an MXC attachment while maximizing the preview size.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import io
10
+ import json
11
+ from typing import Any
12
+
13
+ import nio
14
+ from nio import crypto
15
+
16
+ from mindroom.logging_config import get_logger
17
+
18
+ logger = get_logger(__name__)
19
+
20
+ # Conservative limits accounting for Matrix overhead
21
+ NORMAL_MESSAGE_LIMIT = 55000 # ~55KB for regular messages
22
+ EDIT_MESSAGE_LIMIT = 27000 # ~27KB for edits (they roughly double in size)
23
+
24
+
25
+ def _calculate_event_size(content: dict[str, Any]) -> int:
26
+ """Calculate the approximate size of a Matrix event.
27
+
28
+ Args:
29
+ content: The message content dictionary
30
+
31
+ Returns:
32
+ Approximate size in bytes including JSON overhead
33
+
34
+ """
35
+ # Convert to canonical JSON (sorted keys, no spaces)
36
+ canonical = json.dumps(content, sort_keys=True, separators=(",", ":"))
37
+ # Add ~2KB overhead for event metadata, signatures, etc.
38
+ return len(canonical.encode("utf-8")) + 2000
39
+
40
+
41
+ def _is_edit_message(content: dict[str, Any]) -> bool:
42
+ """Check if this is an edit message."""
43
+ return "m.new_content" in content or (
44
+ "m.relates_to" in content and content.get("m.relates_to", {}).get("rel_type") == "m.replace"
45
+ )
46
+
47
+
48
+ def _create_preview(text: str, max_bytes: int) -> str:
49
+ """Create a preview that fits within byte limit.
50
+
51
+ Args:
52
+ text: The full text to preview
53
+ max_bytes: Maximum size in bytes for the preview
54
+
55
+ Returns:
56
+ Preview text that fits within the byte limit
57
+
58
+ """
59
+ # Reserve space for continuation indicator
60
+ indicator = "\n\n[Message continues in attached file]"
61
+ indicator_bytes = len(indicator.encode("utf-8"))
62
+
63
+ # If text fits entirely, return as-is
64
+ if len(text.encode("utf-8")) <= max_bytes:
65
+ return text
66
+
67
+ # Binary search for the maximum valid UTF-8 substring
68
+ # Account for the indicator from the start
69
+ target_bytes = max_bytes - indicator_bytes
70
+
71
+ # Start with a reasonable estimate
72
+ left, right = 0, min(len(text), target_bytes)
73
+ best_pos = 0
74
+
75
+ while left <= right:
76
+ mid = (left + right) // 2
77
+ try:
78
+ # Check if this position creates valid UTF-8
79
+ test_bytes = text[:mid].encode("utf-8")
80
+ if len(test_bytes) <= target_bytes:
81
+ best_pos = mid
82
+ left = mid + 1
83
+ else:
84
+ right = mid - 1
85
+ except UnicodeEncodeError:
86
+ # Shouldn't happen with valid input
87
+ right = mid - 1
88
+
89
+ return text[:best_pos] + indicator
90
+
91
+
92
+ async def _upload_text_as_mxc(
93
+ client: nio.AsyncClient,
94
+ text: str,
95
+ room_id: str | None = None,
96
+ ) -> tuple[str | None, dict[str, Any] | None]:
97
+ """Upload text content as an MXC file.
98
+
99
+ Args:
100
+ client: The Matrix client
101
+ text: The text content to upload
102
+ room_id: Optional room ID to check for encryption
103
+
104
+ Returns:
105
+ Tuple of (mxc_uri, file_info_dict) or (None, None) on failure
106
+
107
+ """
108
+ text_bytes = text.encode("utf-8")
109
+ file_info = {
110
+ "size": len(text_bytes),
111
+ "mimetype": "text/plain",
112
+ }
113
+
114
+ # Check if room is encrypted
115
+ room_encrypted = False
116
+ if room_id and room_id in client.rooms:
117
+ room = client.rooms[room_id]
118
+ room_encrypted = room.encrypted
119
+
120
+ if room_encrypted:
121
+ # Encrypt the content for E2EE room
122
+ try:
123
+ encrypted_data = crypto.attachments.encrypt_attachment(text_bytes)
124
+ upload_data = encrypted_data["data"]
125
+
126
+ # Store encryption info for the file
127
+ file_info = {
128
+ "url": "", # Will be set after upload
129
+ "key": encrypted_data["key"],
130
+ "iv": encrypted_data["iv"],
131
+ "hashes": encrypted_data["hashes"],
132
+ "v": "v2",
133
+ "mimetype": "text/plain",
134
+ "size": len(text_bytes),
135
+ }
136
+ except Exception:
137
+ logger.exception("Failed to encrypt attachment")
138
+ return None, None
139
+ else:
140
+ upload_data = text_bytes
141
+
142
+ # Upload the file
143
+ def data_provider(_monitor: object, _data: object) -> io.BytesIO:
144
+ return io.BytesIO(upload_data)
145
+
146
+ try:
147
+ # nio.upload returns Tuple[Union[UploadResponse, UploadError], Optional[Dict[str, Any]]]
148
+ upload_result, encryption_dict = await client.upload(
149
+ data_provider=data_provider,
150
+ content_type="application/octet-stream" if room_encrypted else "text/plain",
151
+ filename="message.txt.enc" if room_encrypted else "message.txt",
152
+ filesize=len(upload_data),
153
+ )
154
+
155
+ # Check if upload was successful
156
+ if not isinstance(upload_result, nio.UploadResponse):
157
+ logger.error(f"Failed to upload text: {upload_result}")
158
+ return None, None
159
+
160
+ if not upload_result.content_uri:
161
+ logger.error("Upload response missing content_uri")
162
+ return None, None
163
+
164
+ mxc_uri = str(upload_result.content_uri)
165
+ file_info["url"] = mxc_uri
166
+
167
+ except Exception:
168
+ logger.exception("Failed to upload text")
169
+ return None, None
170
+ else:
171
+ return mxc_uri, file_info
172
+
173
+
174
+ async def prepare_large_message(
175
+ client: nio.AsyncClient,
176
+ room_id: str,
177
+ content: dict[str, Any],
178
+ ) -> dict[str, Any]:
179
+ """Check if message is too large and prepare it if needed.
180
+
181
+ This function:
182
+ 1. Checks the message size
183
+ 2. If too large, uploads the full text as MXC
184
+ 3. Replaces body with maximum-size preview
185
+ 4. Adds metadata for reconstruction
186
+
187
+ Args:
188
+ client: The Matrix client
189
+ room_id: The room to send to
190
+ content: The message content dictionary
191
+
192
+ Returns:
193
+ Original content (if small) or modified content with preview and MXC reference
194
+
195
+ """
196
+ # Edit messages roughly double in size due to m.new_content structure
197
+ # which includes both the edit wrapper and the actual new content
198
+ is_edit = _is_edit_message(content)
199
+ size_limit = EDIT_MESSAGE_LIMIT if is_edit else NORMAL_MESSAGE_LIMIT
200
+
201
+ # Calculate current size
202
+ current_size = _calculate_event_size(content)
203
+
204
+ # If it fits, return unchanged
205
+ if current_size <= size_limit:
206
+ return content
207
+
208
+ # Extract the text to upload (handle both regular and edit messages)
209
+ full_text = content["m.new_content"]["body"] if is_edit and "m.new_content" in content else content["body"]
210
+
211
+ logger.info(f"Message too large ({current_size} bytes), uploading to MXC")
212
+
213
+ # Upload the full text
214
+ mxc_uri, file_info = await _upload_text_as_mxc(client, full_text, room_id)
215
+
216
+ # Calculate how much space we have for preview
217
+ # We'll be sending an m.file message, so account for the file attachment structure
218
+ # The structure adds: filename, url, info object, custom metadata
219
+ attachment_overhead = 5000 # Conservative estimate for attachment JSON structure
220
+ available_for_preview = size_limit - attachment_overhead
221
+
222
+ # Create maximum-size preview
223
+ preview = _create_preview(full_text, available_for_preview)
224
+
225
+ # Create a standard m.file message with preview body
226
+ modified_content = {
227
+ "msgtype": "m.file",
228
+ "body": preview, # Preview text for immediate readability
229
+ "filename": "message.txt",
230
+ "info": file_info,
231
+ }
232
+
233
+ # Add the file URL (either encrypted or plain)
234
+ if room_id and room_id in client.rooms and client.rooms[room_id].encrypted:
235
+ # For encrypted rooms, use 'file' key
236
+ modified_content["file"] = file_info
237
+ else:
238
+ # For unencrypted rooms, use 'url' key
239
+ modified_content["url"] = mxc_uri
240
+
241
+ # Add custom metadata to signal this is a long text message
242
+ # Future custom clients can use this to render as inline text instead of attachment
243
+ modified_content["io.mindroom.long_text"] = {
244
+ "version": 1,
245
+ "original_size": len(full_text),
246
+ "preview_size": len(preview),
247
+ "is_complete_text": True,
248
+ }
249
+
250
+ # Preserve thread/reply relationships if they exist
251
+ if "m.relates_to" in content:
252
+ modified_content["m.relates_to"] = content["m.relates_to"]
253
+
254
+ # Handle edit messages specially
255
+ if is_edit and "m.new_content" in content:
256
+ # For edits, we need to wrap everything in the edit structure
257
+ edit_content = {
258
+ "msgtype": "m.text", # Edit message type
259
+ "body": f"* {preview}",
260
+ "m.new_content": modified_content,
261
+ "m.relates_to": content.get("m.relates_to", {}),
262
+ }
263
+ modified_content = edit_content
264
+
265
+ logger.info(f"Large message prepared: {len(full_text)} bytes -> {len(preview)} preview + MXC attachment")
266
+
267
+ return modified_content