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.
- 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.1.dist-info/METADATA +425 -0
- mindroom-0.1.1.dist-info/RECORD +152 -0
- {mindroom-0.0.0.dist-info → mindroom-0.1.1.dist-info}/WHEEL +1 -2
- mindroom-0.1.1.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,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
|