mmrelay 1.1.4__py3-none-any.whl → 1.2.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.
Potentially problematic release.
This version of mmrelay might be problematic. Click here for more details.
- mmrelay/__init__.py +1 -1
- mmrelay/cli.py +1205 -80
- mmrelay/cli_utils.py +696 -0
- mmrelay/config.py +578 -17
- mmrelay/constants/app.py +12 -0
- mmrelay/constants/config.py +6 -1
- mmrelay/constants/messages.py +10 -1
- mmrelay/constants/network.py +7 -0
- mmrelay/e2ee_utils.py +392 -0
- mmrelay/log_utils.py +39 -5
- mmrelay/main.py +96 -26
- mmrelay/matrix_utils.py +1059 -84
- mmrelay/meshtastic_utils.py +192 -40
- mmrelay/message_queue.py +205 -54
- mmrelay/plugin_loader.py +76 -44
- mmrelay/plugins/base_plugin.py +16 -4
- mmrelay/plugins/weather_plugin.py +108 -11
- mmrelay/tools/sample-docker-compose-prebuilt.yaml +80 -0
- mmrelay/tools/sample-docker-compose.yaml +34 -8
- mmrelay/tools/sample_config.yaml +31 -5
- {mmrelay-1.1.4.dist-info → mmrelay-1.2.1.dist-info}/METADATA +21 -50
- mmrelay-1.2.1.dist-info/RECORD +45 -0
- mmrelay/config_checker.py +0 -162
- mmrelay-1.1.4.dist-info/RECORD +0 -43
- {mmrelay-1.1.4.dist-info → mmrelay-1.2.1.dist-info}/WHEEL +0 -0
- {mmrelay-1.1.4.dist-info → mmrelay-1.2.1.dist-info}/entry_points.txt +0 -0
- {mmrelay-1.1.4.dist-info → mmrelay-1.2.1.dist-info}/licenses/LICENSE +0 -0
- {mmrelay-1.1.4.dist-info → mmrelay-1.2.1.dist-info}/top_level.txt +0 -0
mmrelay/matrix_utils.py
CHANGED
|
@@ -1,30 +1,72 @@
|
|
|
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
|
|
24
65
|
from mmrelay.constants.config import (
|
|
25
|
-
CONFIG_KEY_ACCESS_TOKEN,
|
|
26
|
-
CONFIG_KEY_HOMESERVER,
|
|
27
66
|
CONFIG_SECTION_MATRIX,
|
|
67
|
+
DEFAULT_BROADCAST_ENABLED,
|
|
68
|
+
DEFAULT_DETECTION_SENSOR,
|
|
69
|
+
E2EE_KEY_SHARING_DELAY_SECONDS,
|
|
28
70
|
)
|
|
29
71
|
from mmrelay.constants.database import DEFAULT_MSGS_TO_KEEP
|
|
30
72
|
from mmrelay.constants.formats import (
|
|
@@ -32,7 +74,22 @@ from mmrelay.constants.formats import (
|
|
|
32
74
|
DEFAULT_MESHTASTIC_PREFIX,
|
|
33
75
|
DETECTION_SENSOR_APP,
|
|
34
76
|
)
|
|
35
|
-
from mmrelay.constants.
|
|
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
|
+
)
|
|
36
93
|
from mmrelay.db_utils import (
|
|
37
94
|
get_message_map_by_matrix_event_id,
|
|
38
95
|
prune_message_map,
|
|
@@ -44,15 +101,127 @@ from mmrelay.log_utils import get_logger
|
|
|
44
101
|
from mmrelay.meshtastic_utils import connect_meshtastic, sendTextReply
|
|
45
102
|
from mmrelay.message_queue import get_message_queue, queue_message
|
|
46
103
|
|
|
47
|
-
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))
|
|
48
213
|
|
|
49
214
|
|
|
50
215
|
def _get_msgs_to_keep_config():
|
|
51
216
|
"""
|
|
52
|
-
|
|
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.
|
|
53
222
|
|
|
54
223
|
Returns:
|
|
55
|
-
int: Number of
|
|
224
|
+
int: Number of message mappings to keep.
|
|
56
225
|
"""
|
|
57
226
|
global config
|
|
58
227
|
if not config:
|
|
@@ -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,7 +409,7 @@ 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
|
|
|
@@ -321,7 +492,6 @@ bot_start_time = int(
|
|
|
321
492
|
time.time() * MILLISECONDS_PER_SECOND
|
|
322
493
|
) # Timestamp when the bot starts, used to filter out old messages
|
|
323
494
|
|
|
324
|
-
logger = get_logger(name="Matrix")
|
|
325
495
|
|
|
326
496
|
matrix_client = None
|
|
327
497
|
|
|
@@ -363,9 +533,26 @@ def bot_command(command, event):
|
|
|
363
533
|
|
|
364
534
|
async def connect_matrix(passed_config=None):
|
|
365
535
|
"""
|
|
366
|
-
|
|
536
|
+
Establish and initialize a Matrix AsyncClient connected to the configured homeserver, with optional End-to-End Encryption (E2EE) support.
|
|
537
|
+
|
|
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.
|
|
367
551
|
|
|
368
|
-
|
|
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.
|
|
369
556
|
"""
|
|
370
557
|
global matrix_client, bot_user_name, matrix_homeserver, matrix_rooms, matrix_access_token, bot_user_id, config
|
|
371
558
|
|
|
@@ -378,47 +565,388 @@ async def connect_matrix(passed_config=None):
|
|
|
378
565
|
logger.error("No configuration available. Cannot connect to Matrix.")
|
|
379
566
|
return None
|
|
380
567
|
|
|
381
|
-
# Extract Matrix configuration
|
|
382
|
-
matrix_homeserver = config[CONFIG_SECTION_MATRIX][CONFIG_KEY_HOMESERVER]
|
|
383
|
-
matrix_rooms = config["matrix_rooms"]
|
|
384
|
-
matrix_access_token = config[CONFIG_SECTION_MATRIX][CONFIG_KEY_ACCESS_TOKEN]
|
|
385
|
-
bot_user_id = config["matrix"]["bot_user_id"]
|
|
386
|
-
|
|
387
568
|
# Check if client already exists
|
|
388
569
|
if matrix_client:
|
|
389
570
|
return matrix_client
|
|
390
571
|
|
|
391
|
-
#
|
|
572
|
+
# Check for credentials.json first
|
|
573
|
+
credentials = None
|
|
574
|
+
credentials_path = None
|
|
575
|
+
|
|
576
|
+
# Try to find credentials.json in the config directory
|
|
392
577
|
try:
|
|
393
|
-
|
|
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)
|
|
394
586
|
except Exception as e:
|
|
395
|
-
logger.
|
|
396
|
-
|
|
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
|
|
397
811
|
|
|
398
812
|
# Initialize the Matrix client with custom SSL context
|
|
399
|
-
|
|
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
|
+
|
|
400
825
|
matrix_client = AsyncClient(
|
|
401
826
|
homeserver=matrix_homeserver,
|
|
402
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,
|
|
403
830
|
config=client_config,
|
|
404
831
|
ssl=ssl_context,
|
|
405
832
|
)
|
|
406
833
|
|
|
407
|
-
# Set the access_token and user_id
|
|
408
|
-
|
|
409
|
-
|
|
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
|
+
)
|
|
410
847
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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}")
|
|
416
859
|
else:
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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}")
|
|
420
894
|
else:
|
|
421
|
-
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)
|
|
422
950
|
|
|
423
951
|
# Fetch the bot's display name
|
|
424
952
|
response = await matrix_client.get_displayname(bot_user_id)
|
|
@@ -427,11 +955,233 @@ async def connect_matrix(passed_config=None):
|
|
|
427
955
|
else:
|
|
428
956
|
bot_user_name = bot_user_id # Fallback if display name is not set
|
|
429
957
|
|
|
958
|
+
# Set E2EE status on the client for other functions to access
|
|
959
|
+
matrix_client.e2ee_enabled = e2ee_enabled
|
|
960
|
+
|
|
430
961
|
return matrix_client
|
|
431
962
|
|
|
432
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
|
+
|
|
433
1176
|
async def join_matrix_room(matrix_client, room_id_or_alias: str) -> None:
|
|
434
|
-
"""
|
|
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
|
+
"""
|
|
435
1185
|
try:
|
|
436
1186
|
if room_id_or_alias.startswith("#"):
|
|
437
1187
|
# If it's a room alias, resolve it to a room ID
|
|
@@ -457,7 +1207,7 @@ async def join_matrix_room(matrix_client, room_id_or_alias: str) -> None:
|
|
|
457
1207
|
logger.info(f"Joined room '{room_id_or_alias}' successfully")
|
|
458
1208
|
else:
|
|
459
1209
|
logger.error(
|
|
460
|
-
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))}"
|
|
461
1211
|
)
|
|
462
1212
|
else:
|
|
463
1213
|
logger.debug(f"Bot is already in room '{room_id_or_alias}'")
|
|
@@ -465,6 +1215,21 @@ async def join_matrix_room(matrix_client, room_id_or_alias: str) -> None:
|
|
|
465
1215
|
logger.error(f"Error joining room '{room_id_or_alias}': {e}")
|
|
466
1216
|
|
|
467
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
|
+
|
|
468
1233
|
async def matrix_relay(
|
|
469
1234
|
room_id,
|
|
470
1235
|
message,
|
|
@@ -480,23 +1245,31 @@ async def matrix_relay(
|
|
|
480
1245
|
reply_to_event_id=None,
|
|
481
1246
|
):
|
|
482
1247
|
"""
|
|
483
|
-
Relay a
|
|
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.
|
|
484
1249
|
|
|
485
|
-
|
|
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.
|
|
486
1251
|
|
|
487
1252
|
Parameters:
|
|
488
|
-
room_id (str):
|
|
489
|
-
message (str):
|
|
490
|
-
longname (str):
|
|
491
|
-
shortname (str):
|
|
492
|
-
meshnet_name (str):
|
|
493
|
-
portnum (int):
|
|
494
|
-
meshtastic_id (str, optional):
|
|
495
|
-
meshtastic_replyId (str, optional):
|
|
496
|
-
meshtastic_text (str, optional):
|
|
497
|
-
emote (bool, optional):
|
|
498
|
-
emoji (bool, optional):
|
|
499
|
-
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).
|
|
1270
|
+
|
|
1271
|
+
Returns:
|
|
1272
|
+
None
|
|
500
1273
|
"""
|
|
501
1274
|
global config
|
|
502
1275
|
|
|
@@ -536,14 +1309,55 @@ async def matrix_relay(
|
|
|
536
1309
|
try:
|
|
537
1310
|
# Always use our own local meshnet_name for outgoing events
|
|
538
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
|
+
|
|
539
1348
|
content = {
|
|
540
1349
|
"msgtype": "m.text" if not emote else "m.emote",
|
|
541
|
-
"body":
|
|
1350
|
+
"body": plain_body,
|
|
542
1351
|
"meshtastic_longname": longname,
|
|
543
1352
|
"meshtastic_shortname": shortname,
|
|
544
1353
|
"meshtastic_meshnet": local_meshnet_name,
|
|
545
1354
|
"meshtastic_portnum": portnum,
|
|
546
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
|
|
547
1361
|
if meshtastic_id is not None:
|
|
548
1362
|
content["meshtastic_id"] = meshtastic_id
|
|
549
1363
|
if meshtastic_replyId is not None:
|
|
@@ -569,16 +1383,26 @@ async def matrix_relay(
|
|
|
569
1383
|
original_sender_display = f"{longname}/{original_meshnet}"
|
|
570
1384
|
|
|
571
1385
|
# Create the quoted reply format
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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}"
|
|
575
1394
|
|
|
576
|
-
#
|
|
1395
|
+
# Always use HTML formatting for replies since we need the mx-reply structure
|
|
1396
|
+
content["format"] = "org.matrix.custom.html"
|
|
577
1397
|
reply_link = f"https://matrix.to/#/{room_id}/{reply_to_event_id}"
|
|
578
1398
|
bot_link = f"https://matrix.to/#/@{bot_user_id}"
|
|
579
|
-
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
|
+
)
|
|
580
1404
|
content["formatted_body"] = (
|
|
581
|
-
f"<mx-reply><blockquote>{blockquote_content}</blockquote></mx-reply>{
|
|
1405
|
+
f"<mx-reply><blockquote>{blockquote_content}</blockquote></mx-reply>{formatted_body}"
|
|
582
1406
|
)
|
|
583
1407
|
else:
|
|
584
1408
|
logger.warning(
|
|
@@ -594,13 +1418,66 @@ async def matrix_relay(
|
|
|
594
1418
|
return
|
|
595
1419
|
|
|
596
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
|
+
|
|
597
1473
|
response = await asyncio.wait_for(
|
|
598
1474
|
matrix_client.room_send(
|
|
599
1475
|
room_id=room_id,
|
|
600
1476
|
message_type="m.room.message",
|
|
601
1477
|
content=content,
|
|
1478
|
+
ignore_unverified_devices=True,
|
|
602
1479
|
),
|
|
603
|
-
timeout=
|
|
1480
|
+
timeout=MATRIX_ROOM_SEND_TIMEOUT, # Increased timeout
|
|
604
1481
|
)
|
|
605
1482
|
|
|
606
1483
|
# Log at info level, matching one-point-oh pattern
|
|
@@ -647,7 +1524,7 @@ async def matrix_relay(
|
|
|
647
1524
|
logger.error(f"Error sending radio message to matrix room {room_id}: {e}")
|
|
648
1525
|
|
|
649
1526
|
|
|
650
|
-
def truncate_message(text, max_bytes=
|
|
1527
|
+
def truncate_message(text, max_bytes=DEFAULT_MESSAGE_TRUNCATE_BYTES):
|
|
651
1528
|
"""
|
|
652
1529
|
Truncate the given text to fit within the specified byte size.
|
|
653
1530
|
|
|
@@ -718,16 +1595,39 @@ async def send_reply_to_meshtastic(
|
|
|
718
1595
|
reply_id=None,
|
|
719
1596
|
):
|
|
720
1597
|
"""
|
|
721
|
-
|
|
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.
|
|
1601
|
+
|
|
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
|
|
722
1615
|
|
|
723
|
-
|
|
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.
|
|
724
1619
|
"""
|
|
725
1620
|
meshtastic_interface = connect_meshtastic()
|
|
726
1621
|
from mmrelay.meshtastic_utils import logger as meshtastic_logger
|
|
727
1622
|
|
|
728
1623
|
meshtastic_channel = room_config["meshtastic_channel"]
|
|
729
1624
|
|
|
730
|
-
|
|
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:
|
|
731
1631
|
try:
|
|
732
1632
|
# Create mapping info once if storage is enabled
|
|
733
1633
|
mapping_info = None
|
|
@@ -859,16 +1759,79 @@ async def handle_matrix_reply(
|
|
|
859
1759
|
return True # Reply was handled, stop further processing
|
|
860
1760
|
|
|
861
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
|
+
|
|
862
1796
|
# Callback for new messages in Matrix room
|
|
863
1797
|
async def on_room_message(
|
|
864
1798
|
room: MatrixRoom,
|
|
865
|
-
event: Union[
|
|
1799
|
+
event: Union[
|
|
1800
|
+
RoomMessageText,
|
|
1801
|
+
RoomMessageNotice,
|
|
1802
|
+
ReactionEvent,
|
|
1803
|
+
RoomMessageEmote,
|
|
1804
|
+
],
|
|
866
1805
|
) -> None:
|
|
867
1806
|
"""
|
|
868
|
-
|
|
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.
|
|
869
1823
|
|
|
870
|
-
|
|
1824
|
+
Returns:
|
|
1825
|
+
- None
|
|
871
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
|
+
|
|
872
1835
|
# Importing here to avoid circular imports and to keep logic consistent
|
|
873
1836
|
# Note: We do not call store_message_map directly here for inbound matrix->mesh messages.
|
|
874
1837
|
from mmrelay.message_queue import get_message_queue
|
|
@@ -885,6 +1848,10 @@ async def on_room_message(
|
|
|
885
1848
|
if event.sender == bot_user_id:
|
|
886
1849
|
return
|
|
887
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
|
+
|
|
888
1855
|
# Find the room_config that matches this room, if any
|
|
889
1856
|
room_config = None
|
|
890
1857
|
for room_conf in matrix_rooms:
|
|
@@ -982,12 +1949,12 @@ async def on_room_message(
|
|
|
982
1949
|
):
|
|
983
1950
|
logger.info(f"Relaying reaction from remote meshnet: {meshnet_name}")
|
|
984
1951
|
|
|
985
|
-
short_meshnet_name = meshnet_name[:
|
|
1952
|
+
short_meshnet_name = meshnet_name[:MESHNET_NAME_ABBREVIATION_LENGTH]
|
|
986
1953
|
|
|
987
1954
|
# Format the reaction message for relaying to the local meshnet.
|
|
988
1955
|
# The necessary information is in the m.emote event
|
|
989
1956
|
if not shortname:
|
|
990
|
-
shortname = longname[:
|
|
1957
|
+
shortname = longname[:SHORTNAME_FALLBACK_LENGTH] if longname else "???"
|
|
991
1958
|
|
|
992
1959
|
meshtastic_text_db = event.source["content"].get("meshtastic_text", "")
|
|
993
1960
|
# Strip out any quoted lines from the text
|
|
@@ -997,8 +1964,8 @@ async def on_room_message(
|
|
|
997
1964
|
)
|
|
998
1965
|
|
|
999
1966
|
abbreviated_text = (
|
|
1000
|
-
meshtastic_text_db[:
|
|
1001
|
-
if len(meshtastic_text_db) >
|
|
1967
|
+
meshtastic_text_db[:MESSAGE_PREVIEW_LENGTH] + "..."
|
|
1968
|
+
if len(meshtastic_text_db) > MESSAGE_PREVIEW_LENGTH
|
|
1002
1969
|
else meshtastic_text_db
|
|
1003
1970
|
)
|
|
1004
1971
|
|
|
@@ -1010,7 +1977,9 @@ async def on_room_message(
|
|
|
1010
1977
|
|
|
1011
1978
|
meshtastic_channel = room_config["meshtastic_channel"]
|
|
1012
1979
|
|
|
1013
|
-
if
|
|
1980
|
+
if get_meshtastic_config_value(
|
|
1981
|
+
config, "broadcast_enabled", DEFAULT_BROADCAST_ENABLED, required=False
|
|
1982
|
+
):
|
|
1014
1983
|
meshtastic_logger.info(
|
|
1015
1984
|
f"Relaying reaction from remote meshnet {meshnet_name} to radio broadcast"
|
|
1016
1985
|
)
|
|
@@ -1069,8 +2038,8 @@ async def on_room_message(
|
|
|
1069
2038
|
)
|
|
1070
2039
|
|
|
1071
2040
|
abbreviated_text = (
|
|
1072
|
-
meshtastic_text_db[:
|
|
1073
|
-
if len(meshtastic_text_db) >
|
|
2041
|
+
meshtastic_text_db[:MESSAGE_PREVIEW_LENGTH] + "..."
|
|
2042
|
+
if len(meshtastic_text_db) > MESSAGE_PREVIEW_LENGTH
|
|
1074
2043
|
else meshtastic_text_db
|
|
1075
2044
|
)
|
|
1076
2045
|
|
|
@@ -1083,7 +2052,9 @@ async def on_room_message(
|
|
|
1083
2052
|
|
|
1084
2053
|
meshtastic_channel = room_config["meshtastic_channel"]
|
|
1085
2054
|
|
|
1086
|
-
if
|
|
2055
|
+
if get_meshtastic_config_value(
|
|
2056
|
+
config, "broadcast_enabled", DEFAULT_BROADCAST_ENABLED, required=False
|
|
2057
|
+
):
|
|
1087
2058
|
meshtastic_logger.info(
|
|
1088
2059
|
f"Relaying reaction from {full_display_name} to radio broadcast"
|
|
1089
2060
|
)
|
|
@@ -1129,10 +2100,10 @@ async def on_room_message(
|
|
|
1129
2100
|
if meshnet_name != local_meshnet_name:
|
|
1130
2101
|
# A message from a remote meshnet relayed into Matrix, now going back out
|
|
1131
2102
|
logger.info(f"Processing message from remote meshnet: {meshnet_name}")
|
|
1132
|
-
short_meshnet_name = meshnet_name[:
|
|
2103
|
+
short_meshnet_name = meshnet_name[:MESHNET_NAME_ABBREVIATION_LENGTH]
|
|
1133
2104
|
# If shortname is not available, derive it from the longname
|
|
1134
2105
|
if shortname is None:
|
|
1135
|
-
shortname = longname[:
|
|
2106
|
+
shortname = longname[:SHORTNAME_FALLBACK_LENGTH] if longname else "???"
|
|
1136
2107
|
# Remove the original prefix to avoid double-tagging
|
|
1137
2108
|
# Get the prefix that would have been used for this message
|
|
1138
2109
|
original_prefix = get_matrix_prefix(
|
|
@@ -1163,7 +2134,7 @@ async def on_room_message(
|
|
|
1163
2134
|
prefix = get_meshtastic_prefix(config, full_display_name, event.sender)
|
|
1164
2135
|
logger.debug(f"Processing matrix message from [{full_display_name}]: {text}")
|
|
1165
2136
|
full_message = f"{prefix}{text}"
|
|
1166
|
-
|
|
2137
|
+
full_message = truncate_message(full_message)
|
|
1167
2138
|
|
|
1168
2139
|
# Plugin functionality
|
|
1169
2140
|
from mmrelay.plugin_loader import load_plugins
|
|
@@ -1215,11 +2186,15 @@ async def on_room_message(
|
|
|
1215
2186
|
# Note: If relay_reactions is False, we won't store message_map, but we can still relay.
|
|
1216
2187
|
# The lack of message_map storage just means no reaction bridging will occur.
|
|
1217
2188
|
if not found_matching_plugin:
|
|
1218
|
-
if
|
|
2189
|
+
if get_meshtastic_config_value(
|
|
2190
|
+
config, "broadcast_enabled", DEFAULT_BROADCAST_ENABLED, required=False
|
|
2191
|
+
):
|
|
1219
2192
|
portnum = event.source["content"].get("meshtastic_portnum")
|
|
1220
2193
|
if portnum == DETECTION_SENSOR_APP:
|
|
1221
2194
|
# If detection_sensor is enabled, forward this data as detection sensor data
|
|
1222
|
-
if
|
|
2195
|
+
if get_meshtastic_config_value(
|
|
2196
|
+
config, "detection_sensor", DEFAULT_DETECTION_SENSOR
|
|
2197
|
+
):
|
|
1223
2198
|
success = queue_message(
|
|
1224
2199
|
meshtastic_interface.sendData,
|
|
1225
2200
|
data=full_message.encode("utf-8"),
|
|
@@ -1295,7 +2270,7 @@ async def on_room_message(
|
|
|
1295
2270
|
# Message mapping is now handled automatically by the queue system
|
|
1296
2271
|
else:
|
|
1297
2272
|
logger.debug(
|
|
1298
|
-
f"
|
|
2273
|
+
f"broadcast_enabled is False - not relaying message from {full_display_name} to Meshtastic"
|
|
1299
2274
|
)
|
|
1300
2275
|
|
|
1301
2276
|
|