mmrelay 1.1.3__py3-none-any.whl → 1.2.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.
Potentially problematic release.
This version of mmrelay might be problematic. Click here for more details.
- mmrelay/__init__.py +1 -1
- mmrelay/cli.py +1097 -110
- mmrelay/cli_utils.py +696 -0
- mmrelay/config.py +632 -44
- mmrelay/constants/__init__.py +54 -0
- mmrelay/constants/app.py +29 -0
- mmrelay/constants/config.py +78 -0
- mmrelay/constants/database.py +22 -0
- mmrelay/constants/formats.py +20 -0
- mmrelay/constants/messages.py +45 -0
- mmrelay/constants/network.py +42 -0
- mmrelay/constants/queue.py +17 -0
- mmrelay/db_utils.py +281 -132
- mmrelay/e2ee_utils.py +392 -0
- mmrelay/log_utils.py +77 -19
- mmrelay/main.py +101 -30
- mmrelay/matrix_utils.py +1083 -118
- mmrelay/meshtastic_utils.py +374 -118
- mmrelay/message_queue.py +17 -17
- mmrelay/plugin_loader.py +126 -91
- mmrelay/plugins/base_plugin.py +74 -15
- mmrelay/plugins/drop_plugin.py +13 -5
- mmrelay/plugins/mesh_relay_plugin.py +7 -10
- mmrelay/plugins/weather_plugin.py +118 -12
- mmrelay/setup_utils.py +67 -30
- mmrelay/tools/sample-docker-compose-prebuilt.yaml +80 -0
- mmrelay/tools/sample-docker-compose.yaml +34 -8
- mmrelay/tools/sample_config.yaml +29 -4
- {mmrelay-1.1.3.dist-info → mmrelay-1.2.0.dist-info}/METADATA +21 -50
- mmrelay-1.2.0.dist-info/RECORD +45 -0
- mmrelay/config_checker.py +0 -133
- mmrelay-1.1.3.dist-info/RECORD +0 -35
- {mmrelay-1.1.3.dist-info → mmrelay-1.2.0.dist-info}/WHEEL +0 -0
- {mmrelay-1.1.3.dist-info → mmrelay-1.2.0.dist-info}/entry_points.txt +0 -0
- {mmrelay-1.1.3.dist-info → mmrelay-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {mmrelay-1.1.3.dist-info → mmrelay-1.2.0.dist-info}/top_level.txt +0 -0
mmrelay/matrix_utils.py
CHANGED
|
@@ -1,26 +1,95 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import getpass
|
|
3
|
+
import html
|
|
2
4
|
import io
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
3
8
|
import re
|
|
4
|
-
import
|
|
9
|
+
import sys
|
|
5
10
|
import time
|
|
6
|
-
from typing import Union
|
|
11
|
+
from typing import Any, Dict, Union
|
|
12
|
+
from urllib.parse import urlparse
|
|
7
13
|
|
|
8
|
-
import
|
|
14
|
+
import bleach
|
|
15
|
+
import markdown
|
|
9
16
|
import meshtastic.protobuf.portnums_pb2
|
|
10
17
|
from nio import (
|
|
11
18
|
AsyncClient,
|
|
12
19
|
AsyncClientConfig,
|
|
20
|
+
DiscoveryInfoError,
|
|
21
|
+
DiscoveryInfoResponse,
|
|
13
22
|
MatrixRoom,
|
|
23
|
+
MegolmEvent,
|
|
14
24
|
ReactionEvent,
|
|
15
25
|
RoomMessageEmote,
|
|
16
26
|
RoomMessageNotice,
|
|
17
27
|
RoomMessageText,
|
|
18
28
|
UploadResponse,
|
|
19
|
-
WhoamiError,
|
|
20
29
|
)
|
|
21
30
|
from nio.events.room_events import RoomMemberEvent
|
|
22
31
|
from PIL import Image
|
|
23
32
|
|
|
33
|
+
# Import nio exception types with error handling for test environments
|
|
34
|
+
try:
|
|
35
|
+
from nio.exceptions import LocalProtocolError as NioLocalProtocolError
|
|
36
|
+
from nio.exceptions import LocalTransportError as NioLocalTransportError
|
|
37
|
+
from nio.exceptions import RemoteProtocolError as NioRemoteProtocolError
|
|
38
|
+
from nio.exceptions import RemoteTransportError as NioRemoteTransportError
|
|
39
|
+
from nio.responses import ErrorResponse as NioErrorResponse
|
|
40
|
+
from nio.responses import LoginError as NioLoginError
|
|
41
|
+
from nio.responses import LogoutError as NioLogoutError
|
|
42
|
+
except ImportError:
|
|
43
|
+
# Fallback for test environments where nio imports might fail
|
|
44
|
+
NioLoginError = Exception
|
|
45
|
+
NioLogoutError = Exception
|
|
46
|
+
NioErrorResponse = Exception
|
|
47
|
+
NioLocalProtocolError = Exception
|
|
48
|
+
NioRemoteProtocolError = Exception
|
|
49
|
+
NioLocalTransportError = Exception
|
|
50
|
+
NioRemoteTransportError = Exception
|
|
51
|
+
|
|
52
|
+
from mmrelay.cli_utils import (
|
|
53
|
+
_create_ssl_context,
|
|
54
|
+
msg_require_auth_login,
|
|
55
|
+
msg_retry_auth_login,
|
|
56
|
+
)
|
|
57
|
+
from mmrelay.config import (
|
|
58
|
+
get_base_dir,
|
|
59
|
+
get_e2ee_store_dir,
|
|
60
|
+
get_meshtastic_config_value,
|
|
61
|
+
load_credentials,
|
|
62
|
+
save_credentials,
|
|
63
|
+
)
|
|
64
|
+
from mmrelay.constants.app import WINDOWS_PLATFORM
|
|
65
|
+
from mmrelay.constants.config import (
|
|
66
|
+
CONFIG_SECTION_MATRIX,
|
|
67
|
+
DEFAULT_BROADCAST_ENABLED,
|
|
68
|
+
DEFAULT_DETECTION_SENSOR,
|
|
69
|
+
E2EE_KEY_SHARING_DELAY_SECONDS,
|
|
70
|
+
)
|
|
71
|
+
from mmrelay.constants.database import DEFAULT_MSGS_TO_KEEP
|
|
72
|
+
from mmrelay.constants.formats import (
|
|
73
|
+
DEFAULT_MATRIX_PREFIX,
|
|
74
|
+
DEFAULT_MESHTASTIC_PREFIX,
|
|
75
|
+
DETECTION_SENSOR_APP,
|
|
76
|
+
)
|
|
77
|
+
from mmrelay.constants.messages import (
|
|
78
|
+
DEFAULT_MESSAGE_TRUNCATE_BYTES,
|
|
79
|
+
DISPLAY_NAME_DEFAULT_LENGTH,
|
|
80
|
+
MAX_TRUNCATION_LENGTH,
|
|
81
|
+
MESHNET_NAME_ABBREVIATION_LENGTH,
|
|
82
|
+
MESSAGE_PREVIEW_LENGTH,
|
|
83
|
+
SHORTNAME_FALLBACK_LENGTH,
|
|
84
|
+
TRUNCATION_LOG_LIMIT,
|
|
85
|
+
)
|
|
86
|
+
from mmrelay.constants.network import (
|
|
87
|
+
MATRIX_EARLY_SYNC_TIMEOUT,
|
|
88
|
+
MATRIX_LOGIN_TIMEOUT,
|
|
89
|
+
MATRIX_ROOM_SEND_TIMEOUT,
|
|
90
|
+
MATRIX_SYNC_OPERATION_TIMEOUT,
|
|
91
|
+
MILLISECONDS_PER_SECOND,
|
|
92
|
+
)
|
|
24
93
|
from mmrelay.db_utils import (
|
|
25
94
|
get_message_map_by_matrix_event_id,
|
|
26
95
|
prune_message_map,
|
|
@@ -32,19 +101,131 @@ from mmrelay.log_utils import get_logger
|
|
|
32
101
|
from mmrelay.meshtastic_utils import connect_meshtastic, sendTextReply
|
|
33
102
|
from mmrelay.message_queue import get_message_queue, queue_message
|
|
34
103
|
|
|
35
|
-
logger = get_logger(name="
|
|
104
|
+
logger = get_logger(name="Matrix")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _display_room_channel_mappings(
|
|
108
|
+
rooms: Dict[str, Any], config: Dict[str, Any], e2ee_status: Dict[str, Any]
|
|
109
|
+
) -> None:
|
|
110
|
+
"""
|
|
111
|
+
Log Matrix rooms grouped by Meshtastic channel, showing mapping counts and E2EE/encryption indicators.
|
|
112
|
+
|
|
113
|
+
Reads the "matrix_rooms" entry from config (accepting either dict or list form), builds a mapping from room ID to the configured "meshtastic_channel", then groups and logs rooms ordered by channel number. For each room logs an emoji/status depending on the room's encryption flag and the provided e2ee_status["overall_status"] (common values: "ready", "unavailable", "disabled").
|
|
114
|
+
|
|
115
|
+
Parameters:
|
|
116
|
+
rooms (dict): Mapping of room_id -> room object (room objects should expose at least `display_name` and `encrypted` attributes or fall back to the room_id).
|
|
117
|
+
config (dict): Configuration dict containing a "matrix_rooms" section; entries should include "id" and "meshtastic_channel" when using dict/list room formats.
|
|
118
|
+
e2ee_status (dict): E2EE status information; function expects an "overall_status" key used to determine messaging/encryption indicators.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
None
|
|
122
|
+
"""
|
|
123
|
+
if not rooms:
|
|
124
|
+
logger.info("Bot is not in any Matrix rooms")
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
# Get matrix_rooms configuration
|
|
128
|
+
matrix_rooms_config = config.get("matrix_rooms", [])
|
|
129
|
+
if not matrix_rooms_config:
|
|
130
|
+
logger.info("No matrix_rooms configuration found")
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
# Normalize matrix_rooms configuration to list format
|
|
134
|
+
if isinstance(matrix_rooms_config, dict):
|
|
135
|
+
# Convert dict format to list format
|
|
136
|
+
matrix_rooms_list = list(matrix_rooms_config.values())
|
|
137
|
+
else:
|
|
138
|
+
# Already in list format
|
|
139
|
+
matrix_rooms_list = matrix_rooms_config
|
|
140
|
+
|
|
141
|
+
# Create mapping of room_id -> channel number
|
|
142
|
+
room_to_channel = {}
|
|
143
|
+
for room_config in matrix_rooms_list:
|
|
144
|
+
if isinstance(room_config, dict):
|
|
145
|
+
room_id = room_config.get("id")
|
|
146
|
+
channel = room_config.get("meshtastic_channel")
|
|
147
|
+
if room_id and channel is not None:
|
|
148
|
+
room_to_channel[room_id] = channel
|
|
149
|
+
|
|
150
|
+
# Group rooms by channel
|
|
151
|
+
channels = {}
|
|
152
|
+
|
|
153
|
+
for room_id, room in rooms.items():
|
|
154
|
+
if room_id in room_to_channel:
|
|
155
|
+
channel = room_to_channel[room_id]
|
|
156
|
+
if channel not in channels:
|
|
157
|
+
channels[channel] = []
|
|
158
|
+
channels[channel].append((room_id, room))
|
|
159
|
+
|
|
160
|
+
# Display header
|
|
161
|
+
mapped_rooms = sum(len(room_list) for room_list in channels.values())
|
|
162
|
+
logger.info(f"Matrix Rooms → Meshtastic Channels ({mapped_rooms} configured):")
|
|
163
|
+
|
|
164
|
+
# Display rooms organized by channel (sorted by channel number)
|
|
165
|
+
for channel in sorted(channels.keys()):
|
|
166
|
+
room_list = channels[channel]
|
|
167
|
+
logger.info(f" Channel {channel}:")
|
|
168
|
+
|
|
169
|
+
for room_id, room in room_list:
|
|
170
|
+
room_name = getattr(room, "display_name", room_id)
|
|
171
|
+
encrypted = getattr(room, "encrypted", False)
|
|
172
|
+
|
|
173
|
+
# Format with encryption status
|
|
174
|
+
if e2ee_status["overall_status"] == "ready":
|
|
175
|
+
if encrypted:
|
|
176
|
+
logger.info(f" 🔒 {room_name}")
|
|
177
|
+
else:
|
|
178
|
+
logger.info(f" ✅ {room_name}")
|
|
179
|
+
else:
|
|
180
|
+
if encrypted:
|
|
181
|
+
if e2ee_status["overall_status"] == "unavailable":
|
|
182
|
+
logger.info(
|
|
183
|
+
f" ⚠️ {room_name} (E2EE not supported - messages blocked)"
|
|
184
|
+
)
|
|
185
|
+
elif e2ee_status["overall_status"] == "disabled":
|
|
186
|
+
logger.info(
|
|
187
|
+
f" ⚠️ {room_name} (E2EE disabled - messages blocked)"
|
|
188
|
+
)
|
|
189
|
+
else:
|
|
190
|
+
logger.info(
|
|
191
|
+
f" ⚠️ {room_name} (E2EE incomplete - messages may be blocked)"
|
|
192
|
+
)
|
|
193
|
+
else:
|
|
194
|
+
logger.info(f" ✅ {room_name}")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _can_auto_create_credentials(matrix_config: dict) -> bool:
|
|
198
|
+
"""
|
|
199
|
+
Return True if the Matrix config provides non-empty strings for homeserver, a user id (bot_user_id or user_id), and password.
|
|
200
|
+
|
|
201
|
+
Checks that the `matrix_config` contains the required fields to perform an automatic login flow by ensuring each value exists and is a non-blank string.
|
|
202
|
+
|
|
203
|
+
Parameters:
|
|
204
|
+
matrix_config (dict): The `matrix` section from config.yaml.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
bool: True when homeserver, (bot_user_id or user_id), and password are all present and non-empty strings; otherwise False.
|
|
208
|
+
"""
|
|
209
|
+
homeserver = matrix_config.get("homeserver")
|
|
210
|
+
user = matrix_config.get("bot_user_id") or matrix_config.get("user_id")
|
|
211
|
+
password = matrix_config.get("password")
|
|
212
|
+
return all(isinstance(v, str) and v.strip() for v in (homeserver, user, password))
|
|
36
213
|
|
|
37
214
|
|
|
38
215
|
def _get_msgs_to_keep_config():
|
|
39
216
|
"""
|
|
40
|
-
|
|
217
|
+
Return the configured number of Meshtastic–Matrix message mappings to retain.
|
|
218
|
+
|
|
219
|
+
Reads the global `config` and prefers the new location `database.msg_map.msgs_to_keep`.
|
|
220
|
+
If that section is absent, falls back to the legacy `db.msg_map.msgs_to_keep` and emits a deprecation warning.
|
|
221
|
+
If no configuration is available or `msgs_to_keep` is not set, returns DEFAULT_MSGS_TO_KEEP.
|
|
41
222
|
|
|
42
223
|
Returns:
|
|
43
|
-
int:
|
|
224
|
+
int: Number of message mappings to keep.
|
|
44
225
|
"""
|
|
45
226
|
global config
|
|
46
227
|
if not config:
|
|
47
|
-
return
|
|
228
|
+
return DEFAULT_MSGS_TO_KEEP
|
|
48
229
|
|
|
49
230
|
msg_map_config = config.get("database", {}).get("msg_map", {})
|
|
50
231
|
|
|
@@ -58,26 +239,19 @@ def _get_msgs_to_keep_config():
|
|
|
58
239
|
"Using 'db.msg_map' configuration (legacy). 'database.msg_map' is now the preferred format and 'db.msg_map' will be deprecated in a future version."
|
|
59
240
|
)
|
|
60
241
|
|
|
61
|
-
return msg_map_config.get("msgs_to_keep",
|
|
242
|
+
return msg_map_config.get("msgs_to_keep", DEFAULT_MSGS_TO_KEEP)
|
|
62
243
|
|
|
63
244
|
|
|
64
245
|
def _create_mapping_info(
|
|
65
246
|
matrix_event_id, room_id, text, meshnet=None, msgs_to_keep=None
|
|
66
247
|
):
|
|
67
248
|
"""
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
Removes quoted lines from the message text and includes relevant identifiers and configuration for message retention. Returns `None` if required parameters are missing.
|
|
249
|
+
Create a metadata dictionary linking a Matrix event to a Meshtastic message for message mapping.
|
|
71
250
|
|
|
72
|
-
|
|
73
|
-
matrix_event_id: The Matrix event ID to map.
|
|
74
|
-
room_id: The Matrix room ID where the event occurred.
|
|
75
|
-
text: The message text to be mapped; quoted lines are removed.
|
|
76
|
-
meshnet: Optional name of the target mesh network.
|
|
77
|
-
msgs_to_keep: Optional number of messages to retain for mapping; uses configuration default if not provided.
|
|
251
|
+
Removes quoted lines from the message text and includes identifiers and message retention settings. Returns `None` if any required parameter is missing.
|
|
78
252
|
|
|
79
253
|
Returns:
|
|
80
|
-
dict:
|
|
254
|
+
dict: Mapping information for the message queue, or `None` if required fields are missing.
|
|
81
255
|
"""
|
|
82
256
|
if not matrix_event_id or not room_id or not text:
|
|
83
257
|
return None
|
|
@@ -94,16 +268,11 @@ def _create_mapping_info(
|
|
|
94
268
|
}
|
|
95
269
|
|
|
96
270
|
|
|
97
|
-
# Default prefix format constants
|
|
98
|
-
DEFAULT_MESHTASTIC_PREFIX = "{display5}[M]: "
|
|
99
|
-
DEFAULT_MATRIX_PREFIX = "[{long}/{mesh}]: "
|
|
100
|
-
|
|
101
|
-
|
|
102
271
|
def get_interaction_settings(config):
|
|
103
272
|
"""
|
|
104
|
-
|
|
273
|
+
Determine if message reactions and replies are enabled in the configuration.
|
|
105
274
|
|
|
106
|
-
|
|
275
|
+
Checks for both the new `message_interactions` structure and the legacy `relay_reactions` flag for backward compatibility. Returns a dictionary with boolean values for `reactions` and `replies`, defaulting to both disabled if not specified.
|
|
107
276
|
"""
|
|
108
277
|
if config is None:
|
|
109
278
|
return {"reactions": False, "replies": False}
|
|
@@ -150,10 +319,12 @@ def _add_truncated_vars(format_vars, prefix, text):
|
|
|
150
319
|
# Always add truncated variables, even for empty text (to prevent KeyError)
|
|
151
320
|
text = text or "" # Convert None to empty string
|
|
152
321
|
logger.debug(f"Adding truncated vars for prefix='{prefix}', text='{text}'")
|
|
153
|
-
for i in range(
|
|
322
|
+
for i in range(
|
|
323
|
+
1, MAX_TRUNCATION_LENGTH + 1
|
|
324
|
+
): # Support up to MAX_TRUNCATION_LENGTH chars, always add all variants
|
|
154
325
|
truncated_value = text[:i]
|
|
155
326
|
format_vars[f"{prefix}{i}"] = truncated_value
|
|
156
|
-
if i <=
|
|
327
|
+
if i <= TRUNCATION_LOG_LIMIT: # Only log first few to avoid spam
|
|
157
328
|
logger.debug(f" {prefix}{i} = '{truncated_value}'")
|
|
158
329
|
|
|
159
330
|
|
|
@@ -238,34 +409,25 @@ def get_meshtastic_prefix(config, display_name, user_id=None):
|
|
|
238
409
|
)
|
|
239
410
|
# The default format only uses 'display5', which is safe to format
|
|
240
411
|
return DEFAULT_MESHTASTIC_PREFIX.format(
|
|
241
|
-
display5=display_name[:
|
|
412
|
+
display5=display_name[:DISPLAY_NAME_DEFAULT_LENGTH] if display_name else ""
|
|
242
413
|
)
|
|
243
414
|
|
|
244
415
|
|
|
245
416
|
def get_matrix_prefix(config, longname, shortname, meshnet_name):
|
|
246
417
|
"""
|
|
247
|
-
|
|
418
|
+
Generates a formatted prefix string for Meshtastic messages relayed to Matrix, based on configuration settings and sender/mesh network names.
|
|
419
|
+
|
|
420
|
+
The prefix format supports variable-length truncation for the sender and mesh network names using template variables (e.g., `{long4}` for the first 4 characters of the sender name). Returns an empty string if prefixing is disabled in the configuration.
|
|
248
421
|
|
|
249
422
|
Parameters:
|
|
250
|
-
config (dict): The application configuration dictionary.
|
|
251
423
|
longname (str): Full Meshtastic sender name.
|
|
252
424
|
shortname (str): Short Meshtastic sender name.
|
|
253
425
|
meshnet_name (str): Name of the mesh network.
|
|
254
426
|
|
|
255
427
|
Returns:
|
|
256
|
-
str: The formatted prefix string
|
|
257
|
-
|
|
258
|
-
Examples:
|
|
259
|
-
Basic usage:
|
|
260
|
-
get_matrix_prefix(config, "Alice", "Ali", "MyMesh")
|
|
261
|
-
# Returns: "[Alice/MyMesh]: " (with default format)
|
|
262
|
-
|
|
263
|
-
Custom format:
|
|
264
|
-
config = {"matrix": {"prefix_format": "({long4}): "}}
|
|
265
|
-
get_matrix_prefix(config, "Alice", "Ali", "MyMesh")
|
|
266
|
-
# Returns: "(Alic): "
|
|
428
|
+
str: The formatted prefix string, or an empty string if prefixing is disabled.
|
|
267
429
|
"""
|
|
268
|
-
matrix_config = config.get(
|
|
430
|
+
matrix_config = config.get(CONFIG_SECTION_MATRIX, {})
|
|
269
431
|
|
|
270
432
|
# Enhanced debug logging for configuration troubleshooting
|
|
271
433
|
logger.debug(
|
|
@@ -327,10 +489,9 @@ matrix_access_token = None
|
|
|
327
489
|
bot_user_id = None
|
|
328
490
|
bot_user_name = None # Detected upon logon
|
|
329
491
|
bot_start_time = int(
|
|
330
|
-
time.time() *
|
|
492
|
+
time.time() * MILLISECONDS_PER_SECOND
|
|
331
493
|
) # Timestamp when the bot starts, used to filter out old messages
|
|
332
494
|
|
|
333
|
-
logger = get_logger(name="Matrix")
|
|
334
495
|
|
|
335
496
|
matrix_client = None
|
|
336
497
|
|
|
@@ -372,11 +533,26 @@ def bot_command(command, event):
|
|
|
372
533
|
|
|
373
534
|
async def connect_matrix(passed_config=None):
|
|
374
535
|
"""
|
|
375
|
-
Establish a
|
|
376
|
-
Sets global matrix_client and detects the bot's display name.
|
|
536
|
+
Establish and initialize a Matrix AsyncClient connected to the configured homeserver, with optional End-to-End Encryption (E2EE) support.
|
|
377
537
|
|
|
378
|
-
|
|
379
|
-
|
|
538
|
+
This function will:
|
|
539
|
+
- Prefer credentials.json (E2EE-enabled session) when present; otherwise use the "matrix" section in the provided global configuration.
|
|
540
|
+
- Validate required configuration (including a required top-level "matrix_rooms" mapping).
|
|
541
|
+
- Create an AsyncClient with a certifi-backed SSL context.
|
|
542
|
+
- When E2EE is enabled and supported, prepare the encryption store, load keys, and upload device keys if needed.
|
|
543
|
+
- Perform an initial sync (full_state) to populate room state and then fetch the bot's display name.
|
|
544
|
+
- Return the initialized AsyncClient instance (and set several module-level globals used elsewhere).
|
|
545
|
+
|
|
546
|
+
Parameters:
|
|
547
|
+
passed_config (dict | None): Optional configuration to use instead of the module-level config. If provided, it replaces the global config for this connection attempt.
|
|
548
|
+
|
|
549
|
+
Returns:
|
|
550
|
+
AsyncClient: An initialized matrix-nio AsyncClient instance ready for use, or None when connection cannot be established due to missing credentials/configuration.
|
|
551
|
+
|
|
552
|
+
Raises:
|
|
553
|
+
ValueError: If the top-level "matrix_rooms" configuration is missing.
|
|
554
|
+
ConnectionError: If the initial sync reports a sync error.
|
|
555
|
+
asyncio.TimeoutError: If the initial sync times out.
|
|
380
556
|
"""
|
|
381
557
|
global matrix_client, bot_user_name, matrix_homeserver, matrix_rooms, matrix_access_token, bot_user_id, config
|
|
382
558
|
|
|
@@ -389,43 +565,388 @@ async def connect_matrix(passed_config=None):
|
|
|
389
565
|
logger.error("No configuration available. Cannot connect to Matrix.")
|
|
390
566
|
return None
|
|
391
567
|
|
|
392
|
-
# Extract Matrix configuration
|
|
393
|
-
matrix_homeserver = config["matrix"]["homeserver"]
|
|
394
|
-
matrix_rooms = config["matrix_rooms"]
|
|
395
|
-
matrix_access_token = config["matrix"]["access_token"]
|
|
396
|
-
bot_user_id = config["matrix"]["bot_user_id"]
|
|
397
|
-
|
|
398
568
|
# Check if client already exists
|
|
399
569
|
if matrix_client:
|
|
400
570
|
return matrix_client
|
|
401
571
|
|
|
402
|
-
#
|
|
403
|
-
|
|
572
|
+
# Check for credentials.json first
|
|
573
|
+
credentials = None
|
|
574
|
+
credentials_path = None
|
|
575
|
+
|
|
576
|
+
# Try to find credentials.json in the config directory
|
|
577
|
+
try:
|
|
578
|
+
from mmrelay.config import get_base_dir
|
|
579
|
+
|
|
580
|
+
config_dir = get_base_dir()
|
|
581
|
+
credentials_path = os.path.join(config_dir, "credentials.json")
|
|
582
|
+
|
|
583
|
+
if os.path.exists(credentials_path):
|
|
584
|
+
with open(credentials_path, "r") as f:
|
|
585
|
+
credentials = json.load(f)
|
|
586
|
+
except Exception as e:
|
|
587
|
+
logger.warning(f"Error loading credentials: {e}")
|
|
588
|
+
|
|
589
|
+
# If credentials.json exists, use it
|
|
590
|
+
if credentials:
|
|
591
|
+
matrix_homeserver = credentials["homeserver"]
|
|
592
|
+
matrix_access_token = credentials["access_token"]
|
|
593
|
+
bot_user_id = credentials["user_id"]
|
|
594
|
+
e2ee_device_id = credentials.get("device_id")
|
|
595
|
+
|
|
596
|
+
# Log consolidated credentials info
|
|
597
|
+
logger.debug(f"Using Matrix credentials (device: {e2ee_device_id})")
|
|
598
|
+
|
|
599
|
+
# If device_id is missing, warn but proceed; we'll learn and persist it after restore_login().
|
|
600
|
+
if not isinstance(e2ee_device_id, str) or not e2ee_device_id.strip():
|
|
601
|
+
logger.warning(
|
|
602
|
+
"credentials.json has no valid device_id; proceeding to restore session and discover device_id."
|
|
603
|
+
)
|
|
604
|
+
e2ee_device_id = None
|
|
605
|
+
|
|
606
|
+
# If config also has Matrix login info, let the user know we're ignoring it
|
|
607
|
+
if config and "matrix" in config and "access_token" in config["matrix"]:
|
|
608
|
+
logger.info(
|
|
609
|
+
"NOTE: Ignoring Matrix login details in config.yaml in favor of credentials.json"
|
|
610
|
+
)
|
|
611
|
+
# Check if we can automatically create credentials from config.yaml
|
|
612
|
+
elif (
|
|
613
|
+
config and "matrix" in config and _can_auto_create_credentials(config["matrix"])
|
|
614
|
+
):
|
|
615
|
+
logger.info(
|
|
616
|
+
"No credentials.json found, but config.yaml has password field. Attempting automatic login..."
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
matrix_section = config["matrix"]
|
|
620
|
+
homeserver = matrix_section["homeserver"]
|
|
621
|
+
username = matrix_section.get("bot_user_id") or matrix_section.get("user_id")
|
|
622
|
+
password = matrix_section["password"]
|
|
623
|
+
|
|
624
|
+
# Attempt automatic login
|
|
625
|
+
try:
|
|
626
|
+
success = await login_matrix_bot(
|
|
627
|
+
homeserver=homeserver,
|
|
628
|
+
username=username,
|
|
629
|
+
password=password,
|
|
630
|
+
logout_others=False,
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
if success:
|
|
634
|
+
logger.info(
|
|
635
|
+
"Automatic login successful! Credentials saved to credentials.json"
|
|
636
|
+
)
|
|
637
|
+
# Load the newly created credentials and set up for credentials flow
|
|
638
|
+
credentials = load_credentials()
|
|
639
|
+
if not credentials:
|
|
640
|
+
logger.error("Failed to load newly created credentials")
|
|
641
|
+
return None
|
|
642
|
+
|
|
643
|
+
# Set up variables for credentials-based connection
|
|
644
|
+
matrix_homeserver = credentials["homeserver"]
|
|
645
|
+
matrix_access_token = credentials["access_token"]
|
|
646
|
+
bot_user_id = credentials["user_id"]
|
|
647
|
+
e2ee_device_id = credentials.get("device_id")
|
|
648
|
+
else:
|
|
649
|
+
logger.error(
|
|
650
|
+
"Automatic login failed. Please check your credentials or use 'mmrelay auth login'"
|
|
651
|
+
)
|
|
652
|
+
return None
|
|
653
|
+
except Exception as e:
|
|
654
|
+
logger.error(f"Error during automatic login: {type(e).__name__}")
|
|
655
|
+
logger.error("Please use 'mmrelay auth login' for interactive setup")
|
|
656
|
+
return None
|
|
657
|
+
else:
|
|
658
|
+
# Check if config is available
|
|
659
|
+
if config is None:
|
|
660
|
+
logger.error("No configuration available. Cannot connect to Matrix.")
|
|
661
|
+
return None
|
|
662
|
+
|
|
663
|
+
# Check if matrix section exists in config
|
|
664
|
+
if "matrix" not in config:
|
|
665
|
+
logger.error(
|
|
666
|
+
"No Matrix authentication available. Neither credentials.json nor matrix section in config found."
|
|
667
|
+
)
|
|
668
|
+
logger.error(msg_require_auth_login())
|
|
669
|
+
return None
|
|
670
|
+
|
|
671
|
+
matrix_section = config["matrix"]
|
|
672
|
+
|
|
673
|
+
# Check for required fields in matrix section
|
|
674
|
+
required_fields = ["homeserver", "access_token", "bot_user_id"]
|
|
675
|
+
missing_fields = [
|
|
676
|
+
field for field in required_fields if field not in matrix_section
|
|
677
|
+
]
|
|
678
|
+
|
|
679
|
+
if missing_fields:
|
|
680
|
+
logger.error(f"Matrix section is missing required fields: {missing_fields}")
|
|
681
|
+
logger.error(msg_require_auth_login())
|
|
682
|
+
return None
|
|
683
|
+
|
|
684
|
+
# Extract Matrix configuration from config
|
|
685
|
+
matrix_homeserver = matrix_section["homeserver"]
|
|
686
|
+
matrix_access_token = matrix_section["access_token"]
|
|
687
|
+
bot_user_id = matrix_section["bot_user_id"]
|
|
688
|
+
|
|
689
|
+
# Manual method does not support device_id - use auth system for E2EE
|
|
690
|
+
e2ee_device_id = None
|
|
691
|
+
|
|
692
|
+
# Get matrix rooms from config
|
|
693
|
+
if "matrix_rooms" not in config:
|
|
694
|
+
logger.error("Configuration is missing 'matrix_rooms' section")
|
|
695
|
+
logger.error(
|
|
696
|
+
"Please ensure your config.yaml includes matrix_rooms configuration"
|
|
697
|
+
)
|
|
698
|
+
raise ValueError("Missing required 'matrix_rooms' configuration")
|
|
699
|
+
matrix_rooms = config["matrix_rooms"]
|
|
700
|
+
|
|
701
|
+
# Create SSL context using certifi's certificates with system default fallback
|
|
702
|
+
ssl_context = _create_ssl_context()
|
|
703
|
+
if ssl_context is None:
|
|
704
|
+
logger.warning(
|
|
705
|
+
"Failed to create certifi/system SSL context; proceeding with AsyncClient defaults"
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
# Check if E2EE is enabled
|
|
709
|
+
e2ee_enabled = False
|
|
710
|
+
e2ee_store_path = None
|
|
711
|
+
# Only initialize e2ee_device_id if not already set from credentials
|
|
712
|
+
if "e2ee_device_id" not in locals():
|
|
713
|
+
e2ee_device_id = None
|
|
714
|
+
|
|
715
|
+
try:
|
|
716
|
+
# Check both 'encryption' and 'e2ee' keys for backward compatibility
|
|
717
|
+
matrix_cfg = config.get("matrix", {}) or {}
|
|
718
|
+
encryption_enabled = matrix_cfg.get("encryption", {}).get("enabled", False)
|
|
719
|
+
e2ee_enabled = matrix_cfg.get("e2ee", {}).get("enabled", False)
|
|
720
|
+
if encryption_enabled or e2ee_enabled:
|
|
721
|
+
# Check if running on Windows
|
|
722
|
+
if sys.platform == WINDOWS_PLATFORM:
|
|
723
|
+
logger.error(
|
|
724
|
+
"E2EE is not supported on Windows due to library limitations."
|
|
725
|
+
)
|
|
726
|
+
logger.error(
|
|
727
|
+
"The python-olm library requires native C libraries that are difficult to install on Windows."
|
|
728
|
+
)
|
|
729
|
+
logger.error(
|
|
730
|
+
"Please disable E2EE in your configuration or use a Linux/macOS system for E2EE support."
|
|
731
|
+
)
|
|
732
|
+
e2ee_enabled = False
|
|
733
|
+
else:
|
|
734
|
+
# Check if python-olm is installed
|
|
735
|
+
try:
|
|
736
|
+
import olm # noqa: F401
|
|
737
|
+
|
|
738
|
+
# Also check for other required E2EE dependencies
|
|
739
|
+
try:
|
|
740
|
+
from nio.crypto import OlmDevice # noqa: F401
|
|
741
|
+
from nio.store import SqliteStore # noqa: F401
|
|
742
|
+
|
|
743
|
+
logger.debug("All E2EE dependencies are available")
|
|
744
|
+
except ImportError as e:
|
|
745
|
+
logger.error(f"Missing E2EE dependency: {e}")
|
|
746
|
+
logger.error(
|
|
747
|
+
"Please reinstall with: pipx install 'mmrelay[e2e]'"
|
|
748
|
+
)
|
|
749
|
+
raise RuntimeError(
|
|
750
|
+
"Missing E2EE dependency (Olm/SqliteStore)"
|
|
751
|
+
) from e
|
|
752
|
+
|
|
753
|
+
e2ee_enabled = True
|
|
754
|
+
logger.info("End-to-End Encryption (E2EE) is enabled")
|
|
755
|
+
|
|
756
|
+
# Get store path from config or use default
|
|
757
|
+
if (
|
|
758
|
+
"encryption" in config["matrix"]
|
|
759
|
+
and "store_path" in config["matrix"]["encryption"]
|
|
760
|
+
):
|
|
761
|
+
e2ee_store_path = os.path.expanduser(
|
|
762
|
+
config["matrix"]["encryption"]["store_path"]
|
|
763
|
+
)
|
|
764
|
+
elif (
|
|
765
|
+
"e2ee" in config["matrix"]
|
|
766
|
+
and "store_path" in config["matrix"]["e2ee"]
|
|
767
|
+
):
|
|
768
|
+
e2ee_store_path = os.path.expanduser(
|
|
769
|
+
config["matrix"]["e2ee"]["store_path"]
|
|
770
|
+
)
|
|
771
|
+
else:
|
|
772
|
+
from mmrelay.config import get_e2ee_store_dir
|
|
773
|
+
|
|
774
|
+
e2ee_store_path = get_e2ee_store_dir()
|
|
775
|
+
|
|
776
|
+
# Create store directory if it doesn't exist
|
|
777
|
+
os.makedirs(e2ee_store_path, exist_ok=True)
|
|
778
|
+
|
|
779
|
+
# Check if store directory contains database files
|
|
780
|
+
store_files = (
|
|
781
|
+
os.listdir(e2ee_store_path)
|
|
782
|
+
if os.path.exists(e2ee_store_path)
|
|
783
|
+
else []
|
|
784
|
+
)
|
|
785
|
+
db_files = [f for f in store_files if f.endswith(".db")]
|
|
786
|
+
if db_files:
|
|
787
|
+
logger.debug(
|
|
788
|
+
f"Found existing E2EE store files: {', '.join(db_files)}"
|
|
789
|
+
)
|
|
790
|
+
else:
|
|
791
|
+
logger.warning(
|
|
792
|
+
"No existing E2EE store files found. Encryption may not work correctly."
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
logger.debug(f"Using E2EE store path: {e2ee_store_path}")
|
|
796
|
+
|
|
797
|
+
# If device_id is not present in credentials, we can attempt to learn it later.
|
|
798
|
+
if not e2ee_device_id:
|
|
799
|
+
logger.debug(
|
|
800
|
+
"No device_id in credentials; will retrieve from store/whoami later if available"
|
|
801
|
+
)
|
|
802
|
+
except ImportError:
|
|
803
|
+
logger.warning(
|
|
804
|
+
"E2EE is enabled in config but python-olm is not installed."
|
|
805
|
+
)
|
|
806
|
+
logger.warning("Install 'mmrelay[e2e]' to use E2EE features.")
|
|
807
|
+
e2ee_enabled = False
|
|
808
|
+
except (KeyError, TypeError):
|
|
809
|
+
# E2EE not configured
|
|
810
|
+
pass
|
|
404
811
|
|
|
405
812
|
# Initialize the Matrix client with custom SSL context
|
|
406
|
-
|
|
813
|
+
# Use the same AsyncClientConfig pattern as working E2EE examples
|
|
814
|
+
client_config = AsyncClientConfig(
|
|
815
|
+
max_limit_exceeded=0,
|
|
816
|
+
max_timeouts=0,
|
|
817
|
+
store_sync_tokens=True,
|
|
818
|
+
encryption_enabled=e2ee_enabled,
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
# Log the device ID being used
|
|
822
|
+
if e2ee_device_id:
|
|
823
|
+
logger.debug(f"Device ID from credentials: {e2ee_device_id}")
|
|
824
|
+
|
|
407
825
|
matrix_client = AsyncClient(
|
|
408
826
|
homeserver=matrix_homeserver,
|
|
409
827
|
user=bot_user_id,
|
|
828
|
+
device_id=e2ee_device_id, # Will be None if not specified in config or credentials
|
|
829
|
+
store_path=e2ee_store_path if e2ee_enabled else None,
|
|
410
830
|
config=client_config,
|
|
411
831
|
ssl=ssl_context,
|
|
412
832
|
)
|
|
413
833
|
|
|
414
|
-
# Set the access_token and user_id
|
|
415
|
-
|
|
416
|
-
|
|
834
|
+
# Set the access_token and user_id using restore_login for better session management
|
|
835
|
+
if credentials:
|
|
836
|
+
# Use restore_login method for proper session restoration.
|
|
837
|
+
# nio will handle loading the store automatically if store_path was provided
|
|
838
|
+
# to the client constructor.
|
|
839
|
+
matrix_client.restore_login(
|
|
840
|
+
user_id=bot_user_id,
|
|
841
|
+
device_id=e2ee_device_id,
|
|
842
|
+
access_token=matrix_access_token,
|
|
843
|
+
)
|
|
844
|
+
logger.info(
|
|
845
|
+
f"Restored login session for {bot_user_id} with device {e2ee_device_id}"
|
|
846
|
+
)
|
|
417
847
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
848
|
+
# If the device_id was not known up-front, capture what nio has after restore.
|
|
849
|
+
if not e2ee_device_id and getattr(matrix_client, "device_id", None):
|
|
850
|
+
e2ee_device_id = matrix_client.device_id
|
|
851
|
+
logger.debug(f"Device ID established after restore_login: {e2ee_device_id}")
|
|
852
|
+
try:
|
|
853
|
+
if credentials is not None:
|
|
854
|
+
credentials["device_id"] = e2ee_device_id
|
|
855
|
+
save_credentials(credentials)
|
|
856
|
+
logger.info("Updated credentials.json with discovered device_id")
|
|
857
|
+
except Exception as e:
|
|
858
|
+
logger.debug(f"Failed to persist discovered device_id: {e}")
|
|
423
859
|
else:
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
860
|
+
# Fallback to direct assignment for legacy token-based auth
|
|
861
|
+
matrix_client.access_token = matrix_access_token
|
|
862
|
+
matrix_client.user_id = bot_user_id
|
|
863
|
+
|
|
864
|
+
# If E2EE is enabled, upload keys if necessary.
|
|
865
|
+
# nio will have loaded the store automatically if store_path was provided.
|
|
866
|
+
if e2ee_enabled:
|
|
867
|
+
try:
|
|
868
|
+
if matrix_client.should_upload_keys:
|
|
869
|
+
logger.info("Uploading encryption keys...")
|
|
870
|
+
await matrix_client.keys_upload()
|
|
871
|
+
logger.info("Encryption keys uploaded successfully")
|
|
872
|
+
else:
|
|
873
|
+
logger.debug("No key upload needed - keys already present")
|
|
874
|
+
except Exception as e:
|
|
875
|
+
logger.error(f"Failed to upload E2EE keys: {e}")
|
|
876
|
+
# E2EE might still work, so we don't disable it here
|
|
877
|
+
logger.error("Consider regenerating credentials with: mmrelay auth login")
|
|
878
|
+
|
|
879
|
+
# Perform initial sync to populate rooms (needed for message delivery)
|
|
880
|
+
logger.debug("Performing initial sync to initialize rooms...")
|
|
881
|
+
try:
|
|
882
|
+
# A full_state=True sync is required to get room encryption state
|
|
883
|
+
sync_response = await asyncio.wait_for(
|
|
884
|
+
matrix_client.sync(timeout=MATRIX_EARLY_SYNC_TIMEOUT, full_state=True),
|
|
885
|
+
timeout=MATRIX_SYNC_OPERATION_TIMEOUT,
|
|
886
|
+
)
|
|
887
|
+
# Check if sync failed by looking for error class name
|
|
888
|
+
if (
|
|
889
|
+
hasattr(sync_response, "__class__")
|
|
890
|
+
and "Error" in sync_response.__class__.__name__
|
|
891
|
+
):
|
|
892
|
+
logger.error(f"Initial sync failed: {sync_response}")
|
|
893
|
+
raise ConnectionError(f"Matrix sync failed: {sync_response}")
|
|
427
894
|
else:
|
|
428
|
-
logger.
|
|
895
|
+
logger.info(
|
|
896
|
+
f"Initial sync completed. Found {len(matrix_client.rooms)} rooms."
|
|
897
|
+
)
|
|
898
|
+
|
|
899
|
+
# List all rooms with unified E2EE status display
|
|
900
|
+
from mmrelay.config import config_path
|
|
901
|
+
from mmrelay.e2ee_utils import (
|
|
902
|
+
get_e2ee_status,
|
|
903
|
+
get_room_encryption_warnings,
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
# Get comprehensive E2EE status
|
|
907
|
+
e2ee_status = get_e2ee_status(config, config_path)
|
|
908
|
+
|
|
909
|
+
# Display rooms with channel mappings
|
|
910
|
+
_display_room_channel_mappings(matrix_client.rooms, config, e2ee_status)
|
|
911
|
+
|
|
912
|
+
# Show warnings for encrypted rooms when E2EE is not ready
|
|
913
|
+
warnings = get_room_encryption_warnings(matrix_client.rooms, e2ee_status)
|
|
914
|
+
for warning in warnings:
|
|
915
|
+
logger.warning(warning)
|
|
916
|
+
|
|
917
|
+
# Debug information
|
|
918
|
+
encrypted_count = sum(
|
|
919
|
+
1
|
|
920
|
+
for room in matrix_client.rooms.values()
|
|
921
|
+
if getattr(room, "encrypted", False)
|
|
922
|
+
)
|
|
923
|
+
logger.debug(
|
|
924
|
+
f"Found {encrypted_count} encrypted rooms out of {len(matrix_client.rooms)} total rooms"
|
|
925
|
+
)
|
|
926
|
+
logger.debug(f"E2EE status: {e2ee_status['overall_status']}")
|
|
927
|
+
|
|
928
|
+
# Additional debugging for E2EE enabled case
|
|
929
|
+
if e2ee_enabled and encrypted_count == 0 and len(matrix_client.rooms) > 0:
|
|
930
|
+
logger.debug("No encrypted rooms detected - all rooms are plaintext")
|
|
931
|
+
except asyncio.TimeoutError:
|
|
932
|
+
logger.error(
|
|
933
|
+
f"Initial sync timed out after {MATRIX_SYNC_OPERATION_TIMEOUT} seconds"
|
|
934
|
+
)
|
|
935
|
+
raise
|
|
936
|
+
|
|
937
|
+
# Add a delay to allow for key sharing to complete
|
|
938
|
+
# This addresses a race condition where the client attempts to send encrypted messages
|
|
939
|
+
# before it has received and processed room key sharing messages from other devices.
|
|
940
|
+
# The initial sync() call triggers key sharing requests, but the actual key exchange
|
|
941
|
+
# happens asynchronously. Without this delay, outgoing messages may be sent unencrypted
|
|
942
|
+
# even to encrypted rooms. While not ideal, this timing-based approach is necessary
|
|
943
|
+
# because matrix-nio doesn't provide event-driven alternatives to detect when key
|
|
944
|
+
# sharing is complete.
|
|
945
|
+
if e2ee_enabled:
|
|
946
|
+
logger.debug(
|
|
947
|
+
f"Waiting for {E2EE_KEY_SHARING_DELAY_SECONDS} seconds to allow for key sharing..."
|
|
948
|
+
)
|
|
949
|
+
await asyncio.sleep(E2EE_KEY_SHARING_DELAY_SECONDS)
|
|
429
950
|
|
|
430
951
|
# Fetch the bot's display name
|
|
431
952
|
response = await matrix_client.get_displayname(bot_user_id)
|
|
@@ -434,11 +955,233 @@ async def connect_matrix(passed_config=None):
|
|
|
434
955
|
else:
|
|
435
956
|
bot_user_name = bot_user_id # Fallback if display name is not set
|
|
436
957
|
|
|
958
|
+
# Set E2EE status on the client for other functions to access
|
|
959
|
+
matrix_client.e2ee_enabled = e2ee_enabled
|
|
960
|
+
|
|
437
961
|
return matrix_client
|
|
438
962
|
|
|
439
963
|
|
|
964
|
+
async def login_matrix_bot(
|
|
965
|
+
homeserver=None, username=None, password=None, logout_others=False
|
|
966
|
+
):
|
|
967
|
+
"""
|
|
968
|
+
Perform an interactive Matrix login for the bot, enable end-to-end encryption, and persist credentials for later use.
|
|
969
|
+
|
|
970
|
+
This coroutine attempts server discovery for the provided homeserver, logs in as the given username, initializes an encrypted client store, and saves resulting credentials (homeserver, user_id, access_token, device_id) to credentials.json so the relay can restore the session non-interactively. If an existing credentials.json contains a matching user_id, the device_id will be reused when available.
|
|
971
|
+
|
|
972
|
+
Parameters:
|
|
973
|
+
homeserver (str | None): Homeserver URL to use. If None, the user is prompted.
|
|
974
|
+
username (str | None): Matrix username (without or with leading "@"). If None, the user is prompted.
|
|
975
|
+
password (str | None): Password for the account. If None, the user is prompted securely.
|
|
976
|
+
logout_others (bool | None): If True, attempts to log out other sessions after login. If None, the user is prompted. (Note: full "logout others" behavior may be limited.)
|
|
977
|
+
|
|
978
|
+
Returns:
|
|
979
|
+
bool: True on successful login and credentials persisted; False on failure. The function handles errors internally and returns False rather than raising.
|
|
980
|
+
"""
|
|
981
|
+
try:
|
|
982
|
+
# Enable nio debug logging for detailed connection analysis
|
|
983
|
+
logging.getLogger("nio").setLevel(logging.DEBUG)
|
|
984
|
+
logging.getLogger("nio.client").setLevel(logging.DEBUG)
|
|
985
|
+
logging.getLogger("nio.http_client").setLevel(logging.DEBUG)
|
|
986
|
+
logging.getLogger("aiohttp").setLevel(logging.DEBUG)
|
|
987
|
+
|
|
988
|
+
# Get homeserver URL
|
|
989
|
+
if not homeserver:
|
|
990
|
+
homeserver = input(
|
|
991
|
+
"Enter Matrix homeserver URL (e.g., https://matrix.org): "
|
|
992
|
+
)
|
|
993
|
+
|
|
994
|
+
# Ensure homeserver URL has the correct format
|
|
995
|
+
if not (homeserver.startswith("https://") or homeserver.startswith("http://")):
|
|
996
|
+
homeserver = "https://" + homeserver
|
|
997
|
+
|
|
998
|
+
# Step 1: Perform server discovery to get the actual homeserver URL
|
|
999
|
+
logger.info(f"Performing server discovery for {homeserver}...")
|
|
1000
|
+
|
|
1001
|
+
# Create SSL context using certifi's certificates
|
|
1002
|
+
ssl_context = _create_ssl_context()
|
|
1003
|
+
if ssl_context is None:
|
|
1004
|
+
logger.warning(
|
|
1005
|
+
"Failed to create SSL context for server discovery; falling back to default system SSL"
|
|
1006
|
+
)
|
|
1007
|
+
|
|
1008
|
+
# Create a temporary client for discovery
|
|
1009
|
+
temp_client = AsyncClient(homeserver, "", ssl=ssl_context)
|
|
1010
|
+
try:
|
|
1011
|
+
discovery_response = await asyncio.wait_for(
|
|
1012
|
+
temp_client.discovery_info(), timeout=MATRIX_LOGIN_TIMEOUT
|
|
1013
|
+
)
|
|
1014
|
+
|
|
1015
|
+
if isinstance(discovery_response, DiscoveryInfoResponse):
|
|
1016
|
+
actual_homeserver = discovery_response.homeserver_url
|
|
1017
|
+
logger.info(f"Server discovery successful: {actual_homeserver}")
|
|
1018
|
+
homeserver = actual_homeserver
|
|
1019
|
+
elif isinstance(discovery_response, DiscoveryInfoError):
|
|
1020
|
+
logger.info(
|
|
1021
|
+
f"Server discovery failed, using original URL: {homeserver}"
|
|
1022
|
+
)
|
|
1023
|
+
# Continue with original homeserver URL
|
|
1024
|
+
|
|
1025
|
+
except asyncio.TimeoutError:
|
|
1026
|
+
logger.warning(
|
|
1027
|
+
f"Server discovery timed out, using original URL: {homeserver}"
|
|
1028
|
+
)
|
|
1029
|
+
# Continue with original homeserver URL
|
|
1030
|
+
except Exception as e:
|
|
1031
|
+
logger.warning(
|
|
1032
|
+
f"Server discovery error: {e}, using original URL: {homeserver}"
|
|
1033
|
+
)
|
|
1034
|
+
# Continue with original homeserver URL
|
|
1035
|
+
finally:
|
|
1036
|
+
await temp_client.close()
|
|
1037
|
+
|
|
1038
|
+
# Get username
|
|
1039
|
+
if not username:
|
|
1040
|
+
username = input("Enter Matrix username (without @): ")
|
|
1041
|
+
|
|
1042
|
+
# Format username correctly
|
|
1043
|
+
if not username.startswith("@"):
|
|
1044
|
+
username = f"@{username}"
|
|
1045
|
+
|
|
1046
|
+
server_name = urlparse(homeserver).netloc
|
|
1047
|
+
if ":" not in username:
|
|
1048
|
+
username = f"{username}:{server_name}"
|
|
1049
|
+
|
|
1050
|
+
logger.info(f"Using username: {username}")
|
|
1051
|
+
|
|
1052
|
+
# Get password
|
|
1053
|
+
if not password:
|
|
1054
|
+
password = getpass.getpass("Enter Matrix password: ")
|
|
1055
|
+
|
|
1056
|
+
# Ask about logging out other sessions
|
|
1057
|
+
if logout_others is None:
|
|
1058
|
+
logout_others_input = input(
|
|
1059
|
+
"Log out other sessions? (Y/n) [Default: Yes]: "
|
|
1060
|
+
).lower()
|
|
1061
|
+
logout_others = (
|
|
1062
|
+
not logout_others_input.startswith("n") if logout_others_input else True
|
|
1063
|
+
)
|
|
1064
|
+
|
|
1065
|
+
# Check for existing credentials to reuse device_id
|
|
1066
|
+
existing_device_id = None
|
|
1067
|
+
try:
|
|
1068
|
+
config_dir = get_base_dir()
|
|
1069
|
+
credentials_path = os.path.join(config_dir, "credentials.json")
|
|
1070
|
+
|
|
1071
|
+
if os.path.exists(credentials_path):
|
|
1072
|
+
with open(credentials_path, "r") as f:
|
|
1073
|
+
existing_creds = json.load(f)
|
|
1074
|
+
if (
|
|
1075
|
+
"device_id" in existing_creds
|
|
1076
|
+
and existing_creds["user_id"] == username
|
|
1077
|
+
):
|
|
1078
|
+
existing_device_id = existing_creds["device_id"]
|
|
1079
|
+
logger.info(f"Reusing existing device_id: {existing_device_id}")
|
|
1080
|
+
except Exception as e:
|
|
1081
|
+
logger.debug(f"Could not load existing credentials: {e}")
|
|
1082
|
+
|
|
1083
|
+
# Get the E2EE store path
|
|
1084
|
+
store_path = get_e2ee_store_dir()
|
|
1085
|
+
os.makedirs(store_path, exist_ok=True)
|
|
1086
|
+
logger.debug(f"Using E2EE store path: {store_path}")
|
|
1087
|
+
|
|
1088
|
+
# Create client config for E2EE
|
|
1089
|
+
client_config = AsyncClientConfig(
|
|
1090
|
+
store_sync_tokens=True, encryption_enabled=True
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
# Use the same SSL context as discovery client
|
|
1094
|
+
# ssl_context was created above for discovery
|
|
1095
|
+
|
|
1096
|
+
# Initialize client with E2EE support
|
|
1097
|
+
# Use most common pattern from matrix-nio examples: positional homeserver and user
|
|
1098
|
+
client = AsyncClient(
|
|
1099
|
+
homeserver,
|
|
1100
|
+
username,
|
|
1101
|
+
device_id=existing_device_id,
|
|
1102
|
+
store_path=store_path,
|
|
1103
|
+
config=client_config,
|
|
1104
|
+
ssl=ssl_context,
|
|
1105
|
+
)
|
|
1106
|
+
|
|
1107
|
+
logger.info(f"Logging in as {username} to {homeserver}...")
|
|
1108
|
+
|
|
1109
|
+
# Login with consistent device name and timeout
|
|
1110
|
+
# Use the original working device name
|
|
1111
|
+
device_name = "mmrelay-e2ee"
|
|
1112
|
+
try:
|
|
1113
|
+
# Set device_id on client if we have an existing one
|
|
1114
|
+
if existing_device_id:
|
|
1115
|
+
client.device_id = existing_device_id
|
|
1116
|
+
|
|
1117
|
+
response = await asyncio.wait_for(
|
|
1118
|
+
client.login(password, device_name=device_name),
|
|
1119
|
+
timeout=MATRIX_LOGIN_TIMEOUT,
|
|
1120
|
+
)
|
|
1121
|
+
except asyncio.TimeoutError:
|
|
1122
|
+
logger.error(f"Login timed out after {MATRIX_LOGIN_TIMEOUT} seconds")
|
|
1123
|
+
logger.error(
|
|
1124
|
+
"This may indicate network connectivity issues or a slow Matrix server"
|
|
1125
|
+
)
|
|
1126
|
+
await client.close()
|
|
1127
|
+
return False
|
|
1128
|
+
except Exception as e:
|
|
1129
|
+
# Handle other exceptions during login (e.g., network errors)
|
|
1130
|
+
logger.error(f"Login exception: {e}")
|
|
1131
|
+
logger.error(f"Exception type: {type(e)}")
|
|
1132
|
+
if hasattr(e, "message"):
|
|
1133
|
+
logger.error(f"Exception message: {e.message}")
|
|
1134
|
+
await client.close()
|
|
1135
|
+
return False
|
|
1136
|
+
|
|
1137
|
+
if hasattr(response, "access_token"):
|
|
1138
|
+
logger.info("Login successful!")
|
|
1139
|
+
|
|
1140
|
+
# Save credentials to credentials.json
|
|
1141
|
+
credentials = {
|
|
1142
|
+
"homeserver": homeserver,
|
|
1143
|
+
"user_id": username,
|
|
1144
|
+
"access_token": response.access_token,
|
|
1145
|
+
"device_id": response.device_id,
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
config_dir = get_base_dir()
|
|
1149
|
+
credentials_path = os.path.join(config_dir, "credentials.json")
|
|
1150
|
+
save_credentials(credentials)
|
|
1151
|
+
logger.info(f"Credentials saved to {credentials_path}")
|
|
1152
|
+
|
|
1153
|
+
# Logout other sessions if requested
|
|
1154
|
+
if logout_others:
|
|
1155
|
+
logger.info("Logging out other sessions...")
|
|
1156
|
+
# Note: This would require additional implementation
|
|
1157
|
+
logger.warning("Logout others not yet implemented")
|
|
1158
|
+
|
|
1159
|
+
await client.close()
|
|
1160
|
+
return True
|
|
1161
|
+
else:
|
|
1162
|
+
# Better error logging
|
|
1163
|
+
logger.error(f"Login failed: {response}")
|
|
1164
|
+
if hasattr(response, "message"):
|
|
1165
|
+
logger.error(f"Error message: {response.message}")
|
|
1166
|
+
if hasattr(response, "status_code"):
|
|
1167
|
+
logger.error(f"Status code: {response.status_code}")
|
|
1168
|
+
await client.close()
|
|
1169
|
+
return False
|
|
1170
|
+
|
|
1171
|
+
except Exception as e:
|
|
1172
|
+
logger.error(f"Error during login: {e}")
|
|
1173
|
+
return False
|
|
1174
|
+
|
|
1175
|
+
|
|
440
1176
|
async def join_matrix_room(matrix_client, room_id_or_alias: str) -> None:
|
|
441
|
-
"""
|
|
1177
|
+
"""
|
|
1178
|
+
Join a Matrix room by ID or alias, resolving aliases and updating the local matrix_rooms mapping.
|
|
1179
|
+
|
|
1180
|
+
If given a room alias (starts with '#'), the alias is resolved to a room ID and any entry in the global matrix_rooms list that referenced that alias will be replaced with the resolved room ID. If the bot is not already in the resolved room (or provided room ID), the function attempts to join it. Successes and failures are logged; exceptions are caught and handled internally (the function does not raise).
|
|
1181
|
+
|
|
1182
|
+
Parameters:
|
|
1183
|
+
room_id_or_alias (str): Room ID (e.g. "!abcdef:server") or alias (e.g. "#room:server") to join.
|
|
1184
|
+
"""
|
|
442
1185
|
try:
|
|
443
1186
|
if room_id_or_alias.startswith("#"):
|
|
444
1187
|
# If it's a room alias, resolve it to a room ID
|
|
@@ -464,7 +1207,7 @@ async def join_matrix_room(matrix_client, room_id_or_alias: str) -> None:
|
|
|
464
1207
|
logger.info(f"Joined room '{room_id_or_alias}' successfully")
|
|
465
1208
|
else:
|
|
466
1209
|
logger.error(
|
|
467
|
-
f"Failed to join room '{room_id_or_alias}': {response
|
|
1210
|
+
f"Failed to join room '{room_id_or_alias}': {getattr(response, 'message', str(response))}"
|
|
468
1211
|
)
|
|
469
1212
|
else:
|
|
470
1213
|
logger.debug(f"Bot is already in room '{room_id_or_alias}'")
|
|
@@ -472,6 +1215,21 @@ async def join_matrix_room(matrix_client, room_id_or_alias: str) -> None:
|
|
|
472
1215
|
logger.error(f"Error joining room '{room_id_or_alias}': {e}")
|
|
473
1216
|
|
|
474
1217
|
|
|
1218
|
+
def _get_e2ee_error_message():
|
|
1219
|
+
"""
|
|
1220
|
+
Return a specific error message for why E2EE is not properly enabled.
|
|
1221
|
+
Uses the unified E2EE status system for consistent messaging.
|
|
1222
|
+
"""
|
|
1223
|
+
from mmrelay.config import config_path
|
|
1224
|
+
from mmrelay.e2ee_utils import get_e2ee_error_message, get_e2ee_status
|
|
1225
|
+
|
|
1226
|
+
# Get unified E2EE status
|
|
1227
|
+
e2ee_status = get_e2ee_status(config, config_path)
|
|
1228
|
+
|
|
1229
|
+
# Return unified error message
|
|
1230
|
+
return get_e2ee_error_message(e2ee_status)
|
|
1231
|
+
|
|
1232
|
+
|
|
475
1233
|
async def matrix_relay(
|
|
476
1234
|
room_id,
|
|
477
1235
|
message,
|
|
@@ -487,23 +1245,28 @@ async def matrix_relay(
|
|
|
487
1245
|
reply_to_event_id=None,
|
|
488
1246
|
):
|
|
489
1247
|
"""
|
|
490
|
-
|
|
1248
|
+
Relay a Meshtastic message into a Matrix room, optionally as an emote, emoji-marked message, or as a reply, and record a Meshtastic↔Matrix mapping when configured.
|
|
491
1249
|
|
|
492
|
-
|
|
1250
|
+
Builds a Matrix message payload (plain and HTML/markdown-safe formatted bodies), applies Matrix reply framing when reply_to_event_id is provided, enforces E2EE restrictions (will block sending to encrypted rooms when client E2EE is not enabled), sends the event via the global Matrix client, and — if message-interactions are enabled and a Meshtastic message ID is provided — stores a mapping for future cross-network replies/reactions. Handles timeouts and errors by logging and returning without raising.
|
|
493
1251
|
|
|
494
1252
|
Parameters:
|
|
495
|
-
room_id (str):
|
|
496
|
-
message (str):
|
|
497
|
-
longname (str):
|
|
498
|
-
shortname (str):
|
|
499
|
-
meshnet_name (str):
|
|
500
|
-
portnum (int):
|
|
501
|
-
meshtastic_id (str, optional):
|
|
502
|
-
meshtastic_replyId (str, optional):
|
|
503
|
-
meshtastic_text (str, optional):
|
|
504
|
-
emote (bool, optional):
|
|
505
|
-
emoji (bool, optional):
|
|
506
|
-
reply_to_event_id (str, optional):
|
|
1253
|
+
room_id (str): Matrix room ID or alias to send to.
|
|
1254
|
+
message (str): Message text to relay; may contain Markdown or HTML which will be converted/stripped as needed.
|
|
1255
|
+
longname (str): Sender long display name from Meshtastic used for attribution in formatted output.
|
|
1256
|
+
shortname (str): Sender short display name from Meshtastic.
|
|
1257
|
+
meshnet_name (str): Originating meshnet name (used for metadata/attribution).
|
|
1258
|
+
portnum (int): Meshtastic port number the message originated from.
|
|
1259
|
+
meshtastic_id (str, optional): Meshtastic message ID; when provided and interactions/storage are enabled, a mapping from this Meshtastic ID to the resulting Matrix event will be persisted.
|
|
1260
|
+
meshtastic_replyId (str, optional): Meshtastic message ID being replied to; included as metadata on the Matrix event.
|
|
1261
|
+
meshtastic_text (str, optional): Original Meshtastic message text used when creating stored mappings.
|
|
1262
|
+
emote (bool, optional): If True, send as an m.emote (emote) message instead of regular text.
|
|
1263
|
+
emoji (bool, optional): If True, add emoji metadata to the Matrix event (used to mark emoji-like messages).
|
|
1264
|
+
reply_to_event_id (str, optional): Matrix event ID to which this message should be formatted as an m.in_reply_to reply.
|
|
1265
|
+
|
|
1266
|
+
Side effects:
|
|
1267
|
+
- Sends a message to Matrix using the global matrix client.
|
|
1268
|
+
- May persist a Meshtastic↔Matrix mapping for replies/reactions when storage is enabled.
|
|
1269
|
+
- Logs errors and warnings; does not raise on send failures or storage errors (errors are caught and logged).
|
|
507
1270
|
|
|
508
1271
|
Returns:
|
|
509
1272
|
None
|
|
@@ -540,20 +1303,61 @@ async def matrix_relay(
|
|
|
540
1303
|
"Using 'db.msg_map' configuration (legacy). 'database.msg_map' is now the preferred format and 'db.msg_map' will be deprecated in a future version."
|
|
541
1304
|
)
|
|
542
1305
|
msgs_to_keep = msg_map_config.get(
|
|
543
|
-
"msgs_to_keep",
|
|
544
|
-
) # Default
|
|
1306
|
+
"msgs_to_keep", DEFAULT_MSGS_TO_KEEP
|
|
1307
|
+
) # Default from constants
|
|
545
1308
|
|
|
546
1309
|
try:
|
|
547
1310
|
# Always use our own local meshnet_name for outgoing events
|
|
548
1311
|
local_meshnet_name = config["meshtastic"]["meshnet_name"]
|
|
1312
|
+
|
|
1313
|
+
# Check if message contains HTML tags or markdown formatting
|
|
1314
|
+
has_html = bool(re.search(r"</?[a-zA-Z][^>]*>", message))
|
|
1315
|
+
has_markdown = bool(re.search(r"[*_`~]", message)) # Basic markdown indicators
|
|
1316
|
+
|
|
1317
|
+
# Process markdown to HTML if needed (like base plugin does)
|
|
1318
|
+
if has_markdown or has_html:
|
|
1319
|
+
raw_html = markdown.markdown(message)
|
|
1320
|
+
|
|
1321
|
+
# Sanitize HTML to prevent injection attacks
|
|
1322
|
+
formatted_body = bleach.clean(
|
|
1323
|
+
raw_html,
|
|
1324
|
+
tags=[
|
|
1325
|
+
"b",
|
|
1326
|
+
"strong",
|
|
1327
|
+
"i",
|
|
1328
|
+
"em",
|
|
1329
|
+
"code",
|
|
1330
|
+
"pre",
|
|
1331
|
+
"br",
|
|
1332
|
+
"blockquote",
|
|
1333
|
+
"a",
|
|
1334
|
+
"ul",
|
|
1335
|
+
"ol",
|
|
1336
|
+
"li",
|
|
1337
|
+
"p",
|
|
1338
|
+
],
|
|
1339
|
+
attributes={"a": ["href"]},
|
|
1340
|
+
strip=True,
|
|
1341
|
+
)
|
|
1342
|
+
|
|
1343
|
+
plain_body = re.sub(r"</?[^>]*>", "", formatted_body)
|
|
1344
|
+
else:
|
|
1345
|
+
formatted_body = html.escape(message).replace("\n", "<br/>")
|
|
1346
|
+
plain_body = message
|
|
1347
|
+
|
|
549
1348
|
content = {
|
|
550
1349
|
"msgtype": "m.text" if not emote else "m.emote",
|
|
551
|
-
"body":
|
|
1350
|
+
"body": plain_body,
|
|
552
1351
|
"meshtastic_longname": longname,
|
|
553
1352
|
"meshtastic_shortname": shortname,
|
|
554
1353
|
"meshtastic_meshnet": local_meshnet_name,
|
|
555
1354
|
"meshtastic_portnum": portnum,
|
|
556
1355
|
}
|
|
1356
|
+
|
|
1357
|
+
# Always add format and formatted_body to avoid nio validation errors
|
|
1358
|
+
# where formatted_body becomes None and fails schema validation.
|
|
1359
|
+
content["format"] = "org.matrix.custom.html"
|
|
1360
|
+
content["formatted_body"] = formatted_body
|
|
557
1361
|
if meshtastic_id is not None:
|
|
558
1362
|
content["meshtastic_id"] = meshtastic_id
|
|
559
1363
|
if meshtastic_replyId is not None:
|
|
@@ -579,16 +1383,26 @@ async def matrix_relay(
|
|
|
579
1383
|
original_sender_display = f"{longname}/{original_meshnet}"
|
|
580
1384
|
|
|
581
1385
|
# Create the quoted reply format
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
1386
|
+
safe_original = html.escape(original_text or "")
|
|
1387
|
+
safe_sender_display = re.sub(
|
|
1388
|
+
r"([\\`*_{}[\]()#+.!-])", r"\\\1", original_sender_display
|
|
1389
|
+
)
|
|
1390
|
+
quoted_text = (
|
|
1391
|
+
f"> <@{bot_user_id}> [{safe_sender_display}]: {safe_original}"
|
|
1392
|
+
)
|
|
1393
|
+
content["body"] = f"{quoted_text}\n\n{plain_body}"
|
|
585
1394
|
|
|
586
|
-
#
|
|
1395
|
+
# Always use HTML formatting for replies since we need the mx-reply structure
|
|
1396
|
+
content["format"] = "org.matrix.custom.html"
|
|
587
1397
|
reply_link = f"https://matrix.to/#/{room_id}/{reply_to_event_id}"
|
|
588
1398
|
bot_link = f"https://matrix.to/#/@{bot_user_id}"
|
|
589
|
-
blockquote_content =
|
|
1399
|
+
blockquote_content = (
|
|
1400
|
+
f'<a href="{reply_link}">In reply to</a> '
|
|
1401
|
+
f'<a href="{bot_link}">@{bot_user_id}</a><br>'
|
|
1402
|
+
f"[{html.escape(original_sender_display)}]: {safe_original}"
|
|
1403
|
+
)
|
|
590
1404
|
content["formatted_body"] = (
|
|
591
|
-
f"<mx-reply><blockquote>{blockquote_content}</blockquote></mx-reply>{
|
|
1405
|
+
f"<mx-reply><blockquote>{blockquote_content}</blockquote></mx-reply>{formatted_body}"
|
|
592
1406
|
)
|
|
593
1407
|
else:
|
|
594
1408
|
logger.warning(
|
|
@@ -604,13 +1418,66 @@ async def matrix_relay(
|
|
|
604
1418
|
return
|
|
605
1419
|
|
|
606
1420
|
# Send the message with a timeout
|
|
1421
|
+
# For encrypted rooms, use ignore_unverified_devices=True
|
|
1422
|
+
# After checking working implementations, always use ignore_unverified_devices=True
|
|
1423
|
+
# for text messages to ensure encryption works properly
|
|
1424
|
+
room = (
|
|
1425
|
+
matrix_client.rooms.get(room_id)
|
|
1426
|
+
if matrix_client and hasattr(matrix_client, "rooms")
|
|
1427
|
+
else None
|
|
1428
|
+
)
|
|
1429
|
+
|
|
1430
|
+
# Debug logging for encryption status
|
|
1431
|
+
if room:
|
|
1432
|
+
encrypted_status = getattr(room, "encrypted", "unknown")
|
|
1433
|
+
logger.debug(
|
|
1434
|
+
f"Room {room_id} encryption status: encrypted={encrypted_status}"
|
|
1435
|
+
)
|
|
1436
|
+
|
|
1437
|
+
# Additional E2EE debugging
|
|
1438
|
+
if encrypted_status is True:
|
|
1439
|
+
logger.debug(f"Sending encrypted message to room {room_id}")
|
|
1440
|
+
elif encrypted_status is False:
|
|
1441
|
+
logger.debug(f"Sending unencrypted message to room {room_id}")
|
|
1442
|
+
else:
|
|
1443
|
+
logger.warning(
|
|
1444
|
+
f"Room {room_id} encryption status is unknown - this may indicate E2EE issues"
|
|
1445
|
+
)
|
|
1446
|
+
else:
|
|
1447
|
+
logger.warning(
|
|
1448
|
+
f"Room {room_id} not found in client.rooms - cannot determine encryption status"
|
|
1449
|
+
)
|
|
1450
|
+
|
|
1451
|
+
# Always use ignore_unverified_devices=True for text messages (like matrix-nio-send)
|
|
1452
|
+
logger.debug(
|
|
1453
|
+
"Sending message with ignore_unverified_devices=True (always for text messages)"
|
|
1454
|
+
)
|
|
1455
|
+
|
|
1456
|
+
# Final check: Do not send to encrypted rooms if E2EE is not enabled
|
|
1457
|
+
if (
|
|
1458
|
+
room
|
|
1459
|
+
and getattr(room, "encrypted", False)
|
|
1460
|
+
and not getattr(matrix_client, "e2ee_enabled", False)
|
|
1461
|
+
):
|
|
1462
|
+
room_name = getattr(room, "display_name", room_id)
|
|
1463
|
+
error_message = _get_e2ee_error_message()
|
|
1464
|
+
logger.error(
|
|
1465
|
+
f"🔒 BLOCKED: Cannot send message to encrypted room '{room_name}' ({room_id})"
|
|
1466
|
+
)
|
|
1467
|
+
logger.error(f"Reason: {error_message}")
|
|
1468
|
+
logger.info(
|
|
1469
|
+
"💡 Tip: Run 'mmrelay config check' to validate your E2EE setup"
|
|
1470
|
+
)
|
|
1471
|
+
return
|
|
1472
|
+
|
|
607
1473
|
response = await asyncio.wait_for(
|
|
608
1474
|
matrix_client.room_send(
|
|
609
1475
|
room_id=room_id,
|
|
610
1476
|
message_type="m.room.message",
|
|
611
1477
|
content=content,
|
|
1478
|
+
ignore_unverified_devices=True,
|
|
612
1479
|
),
|
|
613
|
-
timeout=
|
|
1480
|
+
timeout=MATRIX_ROOM_SEND_TIMEOUT, # Increased timeout
|
|
614
1481
|
)
|
|
615
1482
|
|
|
616
1483
|
# Log at info level, matching one-point-oh pattern
|
|
@@ -657,7 +1524,7 @@ async def matrix_relay(
|
|
|
657
1524
|
logger.error(f"Error sending radio message to matrix room {room_id}: {e}")
|
|
658
1525
|
|
|
659
1526
|
|
|
660
|
-
def truncate_message(text, max_bytes=
|
|
1527
|
+
def truncate_message(text, max_bytes=DEFAULT_MESSAGE_TRUNCATE_BYTES):
|
|
661
1528
|
"""
|
|
662
1529
|
Truncate the given text to fit within the specified byte size.
|
|
663
1530
|
|
|
@@ -728,16 +1595,39 @@ async def send_reply_to_meshtastic(
|
|
|
728
1595
|
reply_id=None,
|
|
729
1596
|
):
|
|
730
1597
|
"""
|
|
731
|
-
|
|
1598
|
+
Queue a Matrix-origin reply for transmission over Meshtastic, optionally as a structured reply targeting a specific Meshtastic message.
|
|
1599
|
+
|
|
1600
|
+
If Meshtastic broadcasting is disabled in configuration, the function does nothing. When broadcasting is enabled, it enqueues either a structured reply (if reply_id is provided and supported) or a regular text broadcast. If storage_enabled is True, a message-mapping metadata record is created so the Meshtastic message can be correlated back to the originating Matrix event for future replies/reactions; the mapping retention uses the configured msgs_to_keep value.
|
|
732
1601
|
|
|
733
|
-
|
|
1602
|
+
Parameters:
|
|
1603
|
+
reply_message (str): Message text already formatted for Meshtastic.
|
|
1604
|
+
full_display_name (str): Human-readable sender name to include in message descriptions.
|
|
1605
|
+
room_config (dict): Room-specific configuration; must contain "meshtastic_channel".
|
|
1606
|
+
room: Matrix room object where the original event occurred (used for event and room IDs).
|
|
1607
|
+
event: Matrix event object being replied to (its event_id is used for mapping metadata).
|
|
1608
|
+
text (str): Original Matrix event text (used when creating mapping metadata).
|
|
1609
|
+
storage_enabled (bool): If True, attach mapping metadata to the queued Meshtastic message.
|
|
1610
|
+
local_meshnet_name (str | None): Optional meshnet identifier to include in mapping metadata.
|
|
1611
|
+
reply_id (int | None): Meshtastic message ID to target for a structured reply; if None, a regular broadcast is sent.
|
|
1612
|
+
|
|
1613
|
+
Returns:
|
|
1614
|
+
None
|
|
1615
|
+
|
|
1616
|
+
Notes:
|
|
1617
|
+
- The function logs errors and does not raise; actual transmission is handled asynchronously by the Meshtastic queue system.
|
|
1618
|
+
- Mapping creation uses configured limits (msgs_to_keep) and _create_mapping_info; if mapping creation fails, the message is still attempted without mapping.
|
|
734
1619
|
"""
|
|
735
1620
|
meshtastic_interface = connect_meshtastic()
|
|
736
1621
|
from mmrelay.meshtastic_utils import logger as meshtastic_logger
|
|
737
1622
|
|
|
738
1623
|
meshtastic_channel = room_config["meshtastic_channel"]
|
|
739
1624
|
|
|
740
|
-
|
|
1625
|
+
broadcast_enabled = get_meshtastic_config_value(
|
|
1626
|
+
config, "broadcast_enabled", DEFAULT_BROADCAST_ENABLED, required=False
|
|
1627
|
+
)
|
|
1628
|
+
logger.debug(f"broadcast_enabled = {broadcast_enabled}")
|
|
1629
|
+
|
|
1630
|
+
if broadcast_enabled:
|
|
741
1631
|
try:
|
|
742
1632
|
# Create mapping info once if storage is enabled
|
|
743
1633
|
mapping_info = None
|
|
@@ -869,16 +1759,79 @@ async def handle_matrix_reply(
|
|
|
869
1759
|
return True # Reply was handled, stop further processing
|
|
870
1760
|
|
|
871
1761
|
|
|
1762
|
+
async def on_decryption_failure(room: MatrixRoom, event: MegolmEvent) -> None:
|
|
1763
|
+
"""
|
|
1764
|
+
Handle a MegolmEvent that failed to decrypt by requesting the needed session keys.
|
|
1765
|
+
|
|
1766
|
+
If a received encrypted event cannot be decrypted, this callback logs an error and attempts to request the missing keys from the device that sent them by creating and sending a to-device key request via the module-level Matrix client. The function will:
|
|
1767
|
+
- Set event.room_id to the room's id (monkey-patch) so the key request is properly scoped.
|
|
1768
|
+
- Create a key request from the event and send it with matrix_client.to_device().
|
|
1769
|
+
- Log success or any errors encountered.
|
|
1770
|
+
|
|
1771
|
+
If the module-level Matrix client is not available, the function logs an error and returns without sending a request.
|
|
1772
|
+
"""
|
|
1773
|
+
logger.error(
|
|
1774
|
+
f"Failed to decrypt event '{event.event_id}' in room '{room.room_id}'! "
|
|
1775
|
+
f"This is usually temporary and resolves on its own. "
|
|
1776
|
+
f"If this persists, the bot's session may be corrupt. "
|
|
1777
|
+
f"{msg_retry_auth_login()}."
|
|
1778
|
+
)
|
|
1779
|
+
|
|
1780
|
+
# Attempt to request the keys for the failed event
|
|
1781
|
+
try:
|
|
1782
|
+
if not matrix_client:
|
|
1783
|
+
logger.error("Matrix client not available, cannot request keys.")
|
|
1784
|
+
return
|
|
1785
|
+
|
|
1786
|
+
# Monkey-patch the event object with the correct room_id
|
|
1787
|
+
event.room_id = room.room_id
|
|
1788
|
+
|
|
1789
|
+
request = event.as_key_request(matrix_client.user_id, matrix_client.device_id)
|
|
1790
|
+
await matrix_client.to_device(request)
|
|
1791
|
+
logger.info(f"Requested keys for failed decryption of event {event.event_id}")
|
|
1792
|
+
except Exception as e:
|
|
1793
|
+
logger.error(f"Failed to request keys for event {event.event_id}: {e}")
|
|
1794
|
+
|
|
1795
|
+
|
|
872
1796
|
# Callback for new messages in Matrix room
|
|
873
1797
|
async def on_room_message(
|
|
874
1798
|
room: MatrixRoom,
|
|
875
|
-
event: Union[
|
|
1799
|
+
event: Union[
|
|
1800
|
+
RoomMessageText,
|
|
1801
|
+
RoomMessageNotice,
|
|
1802
|
+
ReactionEvent,
|
|
1803
|
+
RoomMessageEmote,
|
|
1804
|
+
],
|
|
876
1805
|
) -> None:
|
|
877
1806
|
"""
|
|
878
|
-
Handle incoming Matrix room
|
|
1807
|
+
Handle an incoming Matrix room event and relay appropriate content to Meshtastic.
|
|
1808
|
+
|
|
1809
|
+
Processes inbound Matrix events (text, notice, emote, reaction, encrypted events, and reply structures) for supported rooms and, depending on configuration, forwards messages, reactions, and replies to the Meshtastic network. Behavior summary:
|
|
1810
|
+
- Ignores events from before the bot started and events sent by the bot itself.
|
|
1811
|
+
- Logs and notes room encryption changes; encrypted message decryption is handled elsewhere.
|
|
1812
|
+
- Uses per-room configuration to decide whether to process the event; unsupported rooms are ignored.
|
|
1813
|
+
- Honors interaction settings (reactions and replies) and a broadcast_enabled gate for whether Matrix->Meshtastic forwarding occurs.
|
|
1814
|
+
- For reactions: looks up mapped Meshtastic messages and forwards reactions back to the originating mesh when configured; supports special handling for remote-meshnet reactions and emote-derived reactions.
|
|
1815
|
+
- For replies: attempts to find the corresponding Meshtastic message mapping and queue a reply to Meshtastic when enabled.
|
|
1816
|
+
- For regular messages: applies configured prefix formatting, truncation, and special handling for messages originating from remote meshnets; supports detection-sensor forwarding when the port indicates detection data.
|
|
1817
|
+
- Integrates with the plugin system: plugins can handle or consume messages/commands; messages identified as commands directed at the bot are not relayed to Meshtastic.
|
|
1818
|
+
|
|
1819
|
+
Side effects:
|
|
1820
|
+
- May enqueue messages or data to be sent via Meshtastic (via the internal queue system).
|
|
1821
|
+
- May read and consult persistent message mapping storage to support reaction and reply bridging.
|
|
1822
|
+
- May call Matrix APIs to fetch display names.
|
|
879
1823
|
|
|
880
|
-
|
|
1824
|
+
Returns:
|
|
1825
|
+
- None
|
|
881
1826
|
"""
|
|
1827
|
+
# DEBUG: Log all Matrix message events to trace reception
|
|
1828
|
+
logger.debug(
|
|
1829
|
+
f"Received Matrix event in room {room.room_id}: {type(event).__name__}"
|
|
1830
|
+
)
|
|
1831
|
+
logger.debug(
|
|
1832
|
+
f"Event details - sender: {event.sender}, timestamp: {event.server_timestamp}"
|
|
1833
|
+
)
|
|
1834
|
+
|
|
882
1835
|
# Importing here to avoid circular imports and to keep logic consistent
|
|
883
1836
|
# Note: We do not call store_message_map directly here for inbound matrix->mesh messages.
|
|
884
1837
|
from mmrelay.message_queue import get_message_queue
|
|
@@ -895,6 +1848,10 @@ async def on_room_message(
|
|
|
895
1848
|
if event.sender == bot_user_id:
|
|
896
1849
|
return
|
|
897
1850
|
|
|
1851
|
+
# Note: MegolmEvent (encrypted) messages are handled by the `on_decryption_failure`
|
|
1852
|
+
# callback if they fail to decrypt. Successfully decrypted messages are automatically
|
|
1853
|
+
# converted to RoomMessageText/RoomMessageNotice/etc. by matrix-nio and handled normally.
|
|
1854
|
+
|
|
898
1855
|
# Find the room_config that matches this room, if any
|
|
899
1856
|
room_config = None
|
|
900
1857
|
for room_conf in matrix_rooms:
|
|
@@ -992,12 +1949,12 @@ async def on_room_message(
|
|
|
992
1949
|
):
|
|
993
1950
|
logger.info(f"Relaying reaction from remote meshnet: {meshnet_name}")
|
|
994
1951
|
|
|
995
|
-
short_meshnet_name = meshnet_name[:
|
|
1952
|
+
short_meshnet_name = meshnet_name[:MESHNET_NAME_ABBREVIATION_LENGTH]
|
|
996
1953
|
|
|
997
1954
|
# Format the reaction message for relaying to the local meshnet.
|
|
998
1955
|
# The necessary information is in the m.emote event
|
|
999
1956
|
if not shortname:
|
|
1000
|
-
shortname = longname[:
|
|
1957
|
+
shortname = longname[:SHORTNAME_FALLBACK_LENGTH] if longname else "???"
|
|
1001
1958
|
|
|
1002
1959
|
meshtastic_text_db = event.source["content"].get("meshtastic_text", "")
|
|
1003
1960
|
# Strip out any quoted lines from the text
|
|
@@ -1007,8 +1964,8 @@ async def on_room_message(
|
|
|
1007
1964
|
)
|
|
1008
1965
|
|
|
1009
1966
|
abbreviated_text = (
|
|
1010
|
-
meshtastic_text_db[:
|
|
1011
|
-
if len(meshtastic_text_db) >
|
|
1967
|
+
meshtastic_text_db[:MESSAGE_PREVIEW_LENGTH] + "..."
|
|
1968
|
+
if len(meshtastic_text_db) > MESSAGE_PREVIEW_LENGTH
|
|
1012
1969
|
else meshtastic_text_db
|
|
1013
1970
|
)
|
|
1014
1971
|
|
|
@@ -1020,7 +1977,9 @@ async def on_room_message(
|
|
|
1020
1977
|
|
|
1021
1978
|
meshtastic_channel = room_config["meshtastic_channel"]
|
|
1022
1979
|
|
|
1023
|
-
if
|
|
1980
|
+
if get_meshtastic_config_value(
|
|
1981
|
+
config, "broadcast_enabled", DEFAULT_BROADCAST_ENABLED, required=False
|
|
1982
|
+
):
|
|
1024
1983
|
meshtastic_logger.info(
|
|
1025
1984
|
f"Relaying reaction from remote meshnet {meshnet_name} to radio broadcast"
|
|
1026
1985
|
)
|
|
@@ -1079,8 +2038,8 @@ async def on_room_message(
|
|
|
1079
2038
|
)
|
|
1080
2039
|
|
|
1081
2040
|
abbreviated_text = (
|
|
1082
|
-
meshtastic_text_db[:
|
|
1083
|
-
if len(meshtastic_text_db) >
|
|
2041
|
+
meshtastic_text_db[:MESSAGE_PREVIEW_LENGTH] + "..."
|
|
2042
|
+
if len(meshtastic_text_db) > MESSAGE_PREVIEW_LENGTH
|
|
1084
2043
|
else meshtastic_text_db
|
|
1085
2044
|
)
|
|
1086
2045
|
|
|
@@ -1093,7 +2052,9 @@ async def on_room_message(
|
|
|
1093
2052
|
|
|
1094
2053
|
meshtastic_channel = room_config["meshtastic_channel"]
|
|
1095
2054
|
|
|
1096
|
-
if
|
|
2055
|
+
if get_meshtastic_config_value(
|
|
2056
|
+
config, "broadcast_enabled", DEFAULT_BROADCAST_ENABLED, required=False
|
|
2057
|
+
):
|
|
1097
2058
|
meshtastic_logger.info(
|
|
1098
2059
|
f"Relaying reaction from {full_display_name} to radio broadcast"
|
|
1099
2060
|
)
|
|
@@ -1139,10 +2100,10 @@ async def on_room_message(
|
|
|
1139
2100
|
if meshnet_name != local_meshnet_name:
|
|
1140
2101
|
# A message from a remote meshnet relayed into Matrix, now going back out
|
|
1141
2102
|
logger.info(f"Processing message from remote meshnet: {meshnet_name}")
|
|
1142
|
-
short_meshnet_name = meshnet_name[:
|
|
2103
|
+
short_meshnet_name = meshnet_name[:MESHNET_NAME_ABBREVIATION_LENGTH]
|
|
1143
2104
|
# If shortname is not available, derive it from the longname
|
|
1144
2105
|
if shortname is None:
|
|
1145
|
-
shortname = longname[:
|
|
2106
|
+
shortname = longname[:SHORTNAME_FALLBACK_LENGTH] if longname else "???"
|
|
1146
2107
|
# Remove the original prefix to avoid double-tagging
|
|
1147
2108
|
# Get the prefix that would have been used for this message
|
|
1148
2109
|
original_prefix = get_matrix_prefix(
|
|
@@ -1173,7 +2134,7 @@ async def on_room_message(
|
|
|
1173
2134
|
prefix = get_meshtastic_prefix(config, full_display_name, event.sender)
|
|
1174
2135
|
logger.debug(f"Processing matrix message from [{full_display_name}]: {text}")
|
|
1175
2136
|
full_message = f"{prefix}{text}"
|
|
1176
|
-
|
|
2137
|
+
full_message = truncate_message(full_message)
|
|
1177
2138
|
|
|
1178
2139
|
# Plugin functionality
|
|
1179
2140
|
from mmrelay.plugin_loader import load_plugins
|
|
@@ -1225,11 +2186,15 @@ async def on_room_message(
|
|
|
1225
2186
|
# Note: If relay_reactions is False, we won't store message_map, but we can still relay.
|
|
1226
2187
|
# The lack of message_map storage just means no reaction bridging will occur.
|
|
1227
2188
|
if not found_matching_plugin:
|
|
1228
|
-
if
|
|
2189
|
+
if get_meshtastic_config_value(
|
|
2190
|
+
config, "broadcast_enabled", DEFAULT_BROADCAST_ENABLED, required=False
|
|
2191
|
+
):
|
|
1229
2192
|
portnum = event.source["content"].get("meshtastic_portnum")
|
|
1230
|
-
if portnum ==
|
|
2193
|
+
if portnum == DETECTION_SENSOR_APP:
|
|
1231
2194
|
# If detection_sensor is enabled, forward this data as detection sensor data
|
|
1232
|
-
if
|
|
2195
|
+
if get_meshtastic_config_value(
|
|
2196
|
+
config, "detection_sensor", DEFAULT_DETECTION_SENSOR
|
|
2197
|
+
):
|
|
1233
2198
|
success = queue_message(
|
|
1234
2199
|
meshtastic_interface.sendData,
|
|
1235
2200
|
data=full_message.encode("utf-8"),
|
|
@@ -1305,7 +2270,7 @@ async def on_room_message(
|
|
|
1305
2270
|
# Message mapping is now handled automatically by the queue system
|
|
1306
2271
|
else:
|
|
1307
2272
|
logger.debug(
|
|
1308
|
-
f"
|
|
2273
|
+
f"broadcast_enabled is False - not relaying message from {full_display_name} to Meshtastic"
|
|
1309
2274
|
)
|
|
1310
2275
|
|
|
1311
2276
|
|